inkcite 1.8.0 → 1.9.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: d4d3044cbc8db45ebd662ab451d759ef1f0aa25d
4
- data.tar.gz: e87b26d745c307427fd53c7f1e55c19e242f3aaa
3
+ metadata.gz: c9a6108e258e26ebffcba5b417592af73932bede
4
+ data.tar.gz: c6390f145e38ebc22ab57c458e22ce3f1739bb1e
5
5
  SHA512:
6
- metadata.gz: efb49479b68f7745f54902ea4d488c80532345baf772f74f6c939c22a77247c8f9635f4ac73cbf765cab9b3b96319c5af832f06f53c072e5ffd3541cda5a7460
7
- data.tar.gz: 40b7fcc6f1bf820be7bfe5b890730a831b08f48fdd390061584c6808164b19998f1f038a1ad33740365f38689498e192d61f2f4d8719cb6280a235e9fd68b66a
6
+ metadata.gz: 12fa75df86d37aaf12ea3161a16533d2d09c17aa7b04629c7aaffc01cc28d8521d611dbaebc2a3be15c0fa589a81774df7ec6d4b069f923c0e29825ef39c23a0
7
+ data.tar.gz: da6c930926846bd56d665317ddca6014c61b7ac2e43488923ee9678b02a140b9f54aa8fb9c3fbb7abc4ae614289bfcbbe1d094ffa5ebeb28fe5d8d1f18793ffd
data/Rakefile CHANGED
@@ -4,5 +4,5 @@ require 'rake/testtask'
4
4
  Rake::TestTask.new do |t|
5
5
  t.libs.push "lib"
6
6
  t.test_files = FileList['test/*_spec.rb', 'test/renderer/*_spec.rb']
7
- t.verbose = true
7
+ t.verbose = false
8
8
  end
@@ -22,6 +22,13 @@ optimize-images: true
22
22
  # and needs to be provided.
23
23
  missing-link-url: 'https://github.com/404'
24
24
 
25
+ # Litmus (litmus.com) and Email on Acid (emailonacid.com) (paid services)
26
+ # customers enter your static testing address here to enable instant
27
+ # compatibility testing.
28
+ # https://inkcite.readme.io/docs/compatibility-testing
29
+ #
30
+ test-address: ''
31
+
25
32
  # Add Google Fonts (https://www.google.com/fonts) to your emails. Add
26
33
  # the URLs to the family and sizes needed in your email. Then reference
27
34
  # the font family in either source.html:
@@ -78,14 +85,6 @@ recipients:
78
85
  - 'Creative Director <creative.director@domain.com>'
79
86
  - 'Proofreader <proof.reader@domain.com>'
80
87
 
81
- # Easy Litmus integration for compatibility testing.
82
- # https://inkcite.readme.io/docs/compatibility-testing
83
- #
84
- litmus:
85
- subdomain: ''
86
- username: ''
87
- password: ''
88
-
89
88
  # Easy deployment of static assets to a CDN or publicly-accessible
90
89
  # server - required when your email has images.
91
90
  # https://dash.readme.io/project/inkcite/v1.0/docs/cdn-upload
@@ -112,6 +111,17 @@ tag-links: "from_email=myemail|{id}"
112
111
  #
113
112
  #tag-links-domain: 'clientdomain.com'
114
113
 
114
+ # Easy Litmus (litmus.com) analytics integration. Provide your account
115
+ # information here and Inkcite will automatically request a new
116
+ # analytics ID for each version of your email.
117
+ # https://inkcite.readme.io/docs/litmus-analytics
118
+ #
119
+ litmus:
120
+ subdomain: ''
121
+ username: ''
122
+ password: ''
123
+ merge-tag: ''
124
+
115
125
 
116
126
  # Environment-specific overrides allow you to change any setting
117
127
  # for each environment (e.g local development vs. client preview).
@@ -1,6 +1,10 @@
1
1
  require 'thor'
2
2
  require 'fileutils'
3
3
 
4
+ # For improved heredoc
5
+ # http://stackoverflow.com/a/9654275
6
+ require 'active_support/core_ext/string/strip'
7
+
4
8
  module Inkcite
5
9
  module Cli
6
10
  class Base < Thor
@@ -37,8 +41,10 @@ module Inkcite
37
41
  Cli::Init.invoke(name, options)
38
42
  end
39
43
 
40
- desc 'preview TO [options]', 'Send a preview of the email recipient list: developer, internal or client'
41
-
44
+ desc 'preview TO [options]', 'Send a preview of the email to a recipient list: developer, internal or client'
45
+ option :version,
46
+ :aliases => '-v',
47
+ :desc => 'Preview a specific version of the email'
42
48
  def preview to=:developer
43
49
  require_relative 'preview'
44
50
  Cli::Preview.invoke(email, to, options)
@@ -80,12 +86,10 @@ module Inkcite
80
86
 
81
87
  end
82
88
 
83
- desc 'test [options]', 'Tests (or re-tests) the email with Litmus'
84
- option :new,
85
- :aliases => '-n',
86
- :desc => 'Forces a new test to be created, otherwise will revision an existing test if present',
87
- :type => :boolean
88
-
89
+ desc 'test [options]', 'Tests (or re-tests) the email with Litmus or Email on Acid'
90
+ option :version,
91
+ :aliases => '-v',
92
+ :desc => 'Test a specific version of the email'
89
93
  def test
90
94
  require_relative 'test'
91
95
  Cli::Test.invoke(email, options)
@@ -10,15 +10,13 @@ module Inkcite
10
10
  # latest images and "view in browser" versions are available.
11
11
  email.upload
12
12
 
13
- puts "Sending preview to #{to} ..."
14
-
15
13
  case to.to_sym
16
14
  when :client
17
- Inkcite::Mailer.client(email)
15
+ Inkcite::Mailer.client(email, opt)
18
16
  when :internal
19
- Inkcite::Mailer.internal(email)
17
+ Inkcite::Mailer.internal(email, opt)
20
18
  when :developer
21
- Inkcite::Mailer.developer(email)
19
+ Inkcite::Mailer.developer(email, opt)
22
20
  else
23
21
  raise "Invalid preview distribution target"
24
22
  end
@@ -37,7 +37,7 @@ module Inkcite
37
37
  guardfile = <<-EOF
38
38
  guard :livereload do
39
39
  watch(%r{^*.+\.(html|tsv|yml)})
40
- watch(%r{^images/.+\.*})
40
+ watch(%r{images/.+\.(gif|jpg|png)})
41
41
  end
42
42
 
43
43
  logger level: :error
@@ -73,7 +73,15 @@ module Inkcite
73
73
  :app => app
74
74
  })
75
75
  rescue Errno::EADDRINUSE
76
- abort("Oops! Port #{port} is unavailable. Either close the instance of Inkcite already running on #{port} or start this Inkcite instance on a new port with: --port=#{port+1}")
76
+ abort <<-USAGE.strip_heredoc
77
+
78
+ Oops! Inkcite can't start its preview server. Port #{port} is
79
+ unavailable. Either close the instance of Inkcite already running
80
+ on that port or start this Inkcite instance on a new port with:
81
+
82
+ inkcit server --port=#{port+1}
83
+
84
+ USAGE
77
85
  end
78
86
 
79
87
  end
@@ -124,9 +132,9 @@ module Inkcite
124
132
 
125
133
  html = view.render!
126
134
 
127
- # If we're rendering the development version of the email, beautify the
128
- # output so that
129
- html = HtmlBeautifier.beautify(html) if view.development?
135
+ # If minification is disabled, then beautify the output to make it easier
136
+ # for the designer to inspect the code being produced by Inkcite.
137
+ html = HtmlBeautifier.beautify(html) unless view.is_enabled?(:minify)
130
138
 
131
139
  unless view.errors.blank?
132
140
  error_count = view.errors.count
@@ -7,40 +7,59 @@ module Inkcite
7
7
 
8
8
  def self.invoke email, opt
9
9
 
10
- # Verify that a litmus: section is defined in the config.yml
11
- config = email.config[:litmus]
12
- if !config || config.blank?
13
- puts "Unable to test with Litmus ('litmus:' section not found in config.yml)"
14
- return false
10
+ # Check to see if the test-address has been specified.
11
+ send_to = email.config[TEST_ADDRESS]
12
+ if send_to.blank?
13
+
14
+ # Deprecated check for the test address buried in the Litmus section.
15
+ litmus_config = email.config[:litmus]
16
+ send_to = litmus_config[TEST_ADDRESS] unless litmus_config.blank?
17
+
15
18
  end
16
19
 
17
- # The new Litmus launched in October, 2015 no longer uses the API for creating
18
- # tests and instead just accepts emails sent to the account's static email address.
19
- # Check to see if a test-address has been defined.
20
- send_to = config[:'test-address']
21
- if send_to.nil? || send_to.blank?
22
- puts "Unable to test with Litmus! ('test-address' entry missing from 'litmus:' section in the config.yml)"
23
- return false
20
+ if send_to.blank?
21
+ abort <<-USAGE.strip_heredoc
22
+
23
+ Oops! Inkcite can't start a compatibility test because of a missing
24
+ configuration value. In config.yml, please add or uncomment this line
25
+ and insert your Litmus or Email on Acid static testing email address:
26
+
27
+ test-address: '(your.static.address@testingservice.com)'
28
+
29
+ USAGE
24
30
  end
25
31
 
26
32
  # Push the browser preview up to the server to ensure that the
27
33
  # latest images are available.
28
34
  email.upload
29
35
 
30
- # Send each version to Litmus separately
31
- email.versions.each do |version|
36
+ # Typically the user will only provide a single test address but here
37
+ # we convert to an array in case the user is sending to multiple
38
+ # addresses for their own compatibility testing.
39
+ send_to = Array(send_to)
32
40
 
33
- view = email.view(:preview, :email, version)
41
+ # Check to see if the user wants to test a specific version of the
42
+ # email - otherwise test all of them.
43
+ versions = Array(opt[:version] || email.versions)
44
+
45
+ # Send each version to the testing service separately
46
+ versions.each do |version|
34
47
 
35
- puts "Sending '#{view.subject}' to #{send_to} ..."
48
+ view = email.view(:preview, :email, version)
49
+ puts "Sending '#{view.subject}' to #{send_to.join(', ')} ..."
36
50
 
37
- Inkcite::Mailer.litmus(email, version, send_to)
51
+ Inkcite::Mailer.send_version(email, version, { :to => send_to })
38
52
 
39
53
  end
40
54
 
41
55
  true
42
56
  end
43
57
 
58
+ private
59
+
60
+ # Name of the config property that
61
+ TEST_ADDRESS = :'test-address'
62
+
44
63
  end
45
64
  end
46
65
  end
@@ -27,7 +27,7 @@ module Inkcite
27
27
  Util.read_yml(File.join(path, 'config.yml'), :fail_if_not_exists => true)
28
28
  end
29
29
 
30
- def formats env
30
+ def formats env=nil
31
31
 
32
32
  # Inkcite is always capable of producing an email version of
33
33
  # the project.
@@ -4,7 +4,7 @@ require 'mailgun'
4
4
  module Inkcite
5
5
  class Mailer
6
6
 
7
- def self.client email
7
+ def self.client email, opts
8
8
 
9
9
  # Determine which preview this is
10
10
  count = increment(email, :preview)
@@ -27,151 +27,187 @@ module Inkcite
27
27
  # Always cc internal recipients so everyone stays informed of feedback.
28
28
  cc = recipients[:internal]
29
29
 
30
- self.send(email, {
31
- :to => to,
32
- :cc => cc,
33
- :bcc => true,
34
- :tag => "Preview ##{count}"
35
- })
30
+ self.send(email, opts.merge({
31
+ :to => to,
32
+ :cc => cc,
33
+ :bcc => true,
34
+ :tag => "Preview ##{count}"
35
+ }))
36
36
 
37
37
  end
38
38
 
39
- def self.developer email
39
+ def self.developer email, opts
40
40
 
41
41
  count = increment(email, :developer)
42
42
 
43
- self.send(email, {
44
- :tag => "Developer Test ##{count}"
45
- })
43
+ self.send(email, opts.merge({
44
+ :tag => "Developer Test ##{count}"
45
+ }))
46
46
 
47
47
  end
48
48
 
49
- def self.litmus email, version, litmus_email
50
- self.send_version(email, version, { :to => litmus_email })
51
- end
52
-
53
- def self.internal email
49
+ def self.internal email, opts
54
50
 
55
51
  recipients = email.config[:recipients]
56
52
 
57
53
  # Determine which preview this is
58
54
  count = increment(email, :internal)
59
55
 
60
- self.send(email, {
61
- :to => recipients[:internal],
62
- :bcc => true,
63
- :tag => "Internal Proof ##{count}"
64
- })
56
+ self.send(email, opts.merge({
57
+ :to => recipients[:internal],
58
+ :bcc => true,
59
+ :tag => "Internal Proof ##{count}"
60
+ }))
65
61
 
66
62
  end
67
63
 
68
- private
64
+ # Sends each version of the provided email with the indicated options.
65
+ def self.send email, opts
69
66
 
70
- # Name of the distribution list used on the first preview. For one
71
- # client, they wanted the first preview sent to additional people
72
- # but subsequent previews went to a shorter list.
73
- FIRST_PREVIEW = :'first-preview'
67
+ # Check to see if a specific version is requested or if unspecified
68
+ # all versions of the email should be sent.
69
+ versions = Array(opts[:version] || email.versions)
74
70
 
75
- def self.increment email, sym
76
- count = email.meta(sym).to_i + 1
77
- email.set_meta sym, count
78
- end
71
+ # Will hold the instance of the Mailer::Base that will handle the
72
+ # actual sending of the email.
73
+ mailer_base = nil
79
74
 
80
- # Sends each version of the provided email with the indicated options.
81
- def self.send email, opt
75
+ # Check to see if
76
+ if config = email.config[:mailgun]
77
+ mailer_base = MailgunMailer.new
78
+ elsif config = email.config[:smtp]
79
+ mailer_base = SmtpMailer.new
80
+ else
81
+ abort <<-USAGE.strip_heredoc
82
+
83
+ Oops! Inkcite can't send this email because of a configuration problem.
84
+ Please update the mailgun or smtp sections of your config.yml file.
85
+
86
+ smtp:
87
+ host: 'smtp.gmail.com'
88
+ port: 587
89
+ domain: 'yourdomain.com'
90
+ username: ''
91
+ password: ''
92
+ from: 'Your Name <email@domain.com>'
82
93
 
83
- email.versions.each do |version|
84
- self.send_version(email, version, opt)
94
+ Or send via Mailgun:
95
+
96
+ mailgun:
97
+ api-key: 'key-your-api-key'
98
+ domain: 'mg.sending-domain.com'
99
+ from: 'Your Name <email@domain.com>'
100
+
101
+ USAGE
85
102
  end
86
103
 
87
- end
104
+ versions.each do |version|
88
105
 
89
- def self.send_version email, version, opt
106
+ # The version of the email we will be sending.
107
+ view = email.view(:preview, :email, version)
90
108
 
91
- # The version of the email we will be sending.
92
- view = email.view(:preview, :email, version)
109
+ # Subject line tag such as "Preview #3"
110
+ tag = opts[:tag]
93
111
 
94
- # Subject line tag such as "Preview #3"
95
- tag = opt[:tag]
112
+ subject = view.subject
113
+ subject = "#{subject} (#{tag})" unless tag.blank?
96
114
 
97
- subject = view.subject
98
- subject = "#{subject} (#{tag})" unless tag.blank?
115
+ puts "Sending '#{subject}' ..."
116
+
117
+ mailer_base.send! config, view, subject, opts
99
118
 
100
- if config = email.config[:mailgun]
101
- send_version_via_mailgun config, view, subject, opt
102
- elsif config = email.config[:smtp]
103
- send_version_via_smtp config, view, subject, opt
104
- else
105
- puts 'Unable to send previews. Please configure mailgun or smtp sections in config.yml'
106
119
  end
107
120
 
108
121
  end
109
122
 
110
123
  private
111
124
 
112
- def self.send_version_via_mailgun config, view, subject, opt
125
+ # Name of the distribution list used on the first preview. For one
126
+ # client, they wanted the first preview sent to additional people
127
+ # but subsequent previews went to a shorter list.
128
+ FIRST_PREVIEW = :'first-preview'
113
129
 
114
- # The address of the developer
115
- from = config[:from]
130
+ def self.increment email, sym
131
+ count = email.meta(sym).to_i + 1
132
+ email.set_meta sym, count
133
+ end
116
134
 
117
- # First, instantiate the Mailgun Client with your API key
118
- mg_client = Mailgun::Client.new config[:'api-key']
135
+ # Abstract base class for the workhorses of the Mailer class.
136
+ # Instantiated based on the config.yml settings.
137
+ class Base
138
+ def send! config, view, subject, opt
139
+ raise NotImplementedError
140
+ end
141
+ end
119
142
 
120
- # Define your message parameters
121
- message_params = {
122
- :from => from,
123
- :to => opt[:to] || from,
124
- :subject => subject,
125
- :html => view.render!
126
- }
143
+ class MailgunMailer < Base
144
+ def send! config, view, subject, opt
127
145
 
128
- message_params[:cc] = opt[:cc] unless opt[:cc].blank?
129
- message_params[:bcc] = from if opt[:bcc] == true
146
+ # The address of the developer
147
+ from = config[:from]
130
148
 
131
- # Send your message through the client
132
- mg_client.send_message config[:domain], message_params
149
+ # First, instantiate the Mailgun Client with your API key
150
+ mg_client = Mailgun::Client.new config[:'api-key']
133
151
 
134
- end
152
+ # Define your message parameters
153
+ message_params = {
154
+ :from => from,
155
+ :to => opt[:to] || from,
156
+ :subject => subject,
157
+ :html => view.render!
158
+ }
159
+
160
+ message_params[:cc] = opt[:cc] unless opt[:cc].blank?
161
+ message_params[:bcc] = from if opt[:bcc] == true
162
+
163
+ # Send your message through the client
164
+ mg_client.send_message config[:domain], message_params
135
165
 
136
- def self.send_version_via_smtp config, view, _subject, opt
137
-
138
- Mail.defaults do
139
- delivery_method :smtp, {
140
- :address => config[:host],
141
- :port => config[:port],
142
- :user_name => config[:username],
143
- :password => config[:password],
144
- :authentication => :plain,
145
- :enable_starttls_auto => true
146
- }
147
166
  end
167
+ end
148
168
 
149
- # The address of the developer
150
- _from = config[:from]
169
+ class SmtpMailer < Base
170
+ def send! config, view, _subject, opt
171
+
172
+ Mail.defaults do
173
+ delivery_method :smtp, {
174
+ :address => config[:host],
175
+ :port => config[:port],
176
+ :user_name => config[:username],
177
+ :password => config[:password],
178
+ :authentication => :plain,
179
+ :enable_starttls_auto => true
180
+ }
181
+ end
151
182
 
152
- # True if the developer should be bcc'd.
153
- _bcc = opt[:bcc] == true
183
+ # The address of the developer
184
+ _from = config[:from]
154
185
 
155
- mail = Mail.new do
186
+ # True if the developer should be bcc'd.
187
+ _bcc = !!opt[:bcc]
156
188
 
157
- to opt[:to] || _from
158
- cc opt[:cc]
159
- from _from
160
- subject _subject
189
+ mail = Mail.new do
161
190
 
162
- bcc(_from) if _bcc
191
+ to opt[:to] || _from
192
+ cc opt[:cc]
193
+ from _from
194
+ subject _subject
163
195
 
164
- html_part do
165
- content_type 'text/html; charset=UTF-8'
166
- body view.render!
167
- end
196
+ bcc(_from) if _bcc
168
197
 
169
- end
198
+ html_part do
199
+ content_type 'text/html; charset=UTF-8'
200
+ body view.render!
201
+ end
202
+
203
+ end
170
204
 
171
- mail.deliver!
205
+ mail.deliver!
172
206
 
207
+ end
173
208
  end
174
209
 
175
210
  end
176
211
  end
177
212
 
213
+
@@ -74,6 +74,10 @@ module Inkcite
74
74
  val
75
75
  end
76
76
 
77
+ def detect_bgcolor opt
78
+ detect(opt[:bgcolor], opt[BACKGROUND_COLOR])
79
+ end
80
+
77
81
  # Convenience pass-thru to Renderer's static helper method.
78
82
  def hex color
79
83
  Renderer.hex(color)
@@ -92,7 +96,7 @@ module Inkcite
92
96
  def mix_background element, opt
93
97
 
94
98
  # Background color of the image, if populated.
95
- bgcolor = detect(opt[:bgcolor], opt[BACKGROUND_COLOR])
99
+ bgcolor = detect_bgcolor(opt)
96
100
  element.style[BACKGROUND_COLOR] = hex(bgcolor) unless none?(bgcolor)
97
101
 
98
102
  end
@@ -5,10 +5,13 @@ module Inkcite
5
5
  protected
6
6
 
7
7
  # Display mode constants
8
- BLOCK = 'block'
8
+ BLOCK = 'block'
9
9
  DEFAULT = 'default'
10
- INLINE = 'inline'
10
+ INLINE = 'inline'
11
11
 
12
+ # For the given image source URL provided, returns either the fully-qualfied
13
+ # path to the image (via View's image_url method) or returns a placeholder
14
+ # if the image is missing.
12
15
  def image_url _src, opt, ctx
13
16
 
14
17
  src = _src
@@ -18,7 +21,7 @@ module Inkcite
18
21
 
19
22
  # Fully-qualify the image path for this version of the email unless it
20
23
  # is already includes a full address.
21
- unless src.include?('://')
24
+ unless Util::is_fully_qualified?(src)
22
25
 
23
26
  # Verify that the image exists.
24
27
  if ctx.assert_image_exists(src) || ctx.is_disabled?(Inkcite::Email::IMAGE_PLACEHOLDERS)
@@ -36,15 +39,25 @@ module Inkcite
36
39
 
37
40
  elsif DIMENSIONS.all? { |dim| opt[dim].to_i > MINIMUM_DIMENSION_FOR_PLACEHOLDER }
38
41
 
42
+ width = opt[:width]
43
+ height = opt[:height]
44
+
39
45
  # As a convenience, replace missing images with placehold.it as long as they
40
46
  # meet the minimum dimensions. No need to spam the design with tiny, tiny
41
47
  # placeholders.
42
- src = "http://placehold.it/#{opt[:width]}x#{opt[:height]}#{File.extname(src)}"
48
+ src = "http://placehold.it/#{width}x#{height}.jpg"
49
+
50
+ # Check to see if the image has a background color. If so, we'll use that
51
+ # to set the background color of the placeholder.
52
+ bgcolor = detect_bgcolor(opt)
53
+ src << "/#{bgcolor}".gsub('#', '') unless none?(bgcolor)
43
54
 
44
55
  # Check to see if the designer specified FPO text for this placeholder -
45
56
  # otherwise default to the dimensions of the image.
46
57
  fpo = opt[:fpo]
47
- src << "&text=#{URI::encode(fpo)}" unless fpo.blank?
58
+ fpo = _src.dup if fpo.blank?
59
+ fpo << "\n(#{width}×#{height})"
60
+ src << "?text=#{URI::encode(fpo)}"
48
61
 
49
62
  end
50
63
 
@@ -44,7 +44,7 @@ module Inkcite
44
44
 
45
45
  # If a URL wasn't provided in the HTML, then check to see if there is
46
46
  # a link declared in the project's links_tsv file.
47
- href = ctx.links_tsv[id] if href.blank?
47
+ href = ctx.links_tsv[id].dup if href.blank? && ctx.links_tsv[id]
48
48
 
49
49
  # True if the href is missing. If so, we may try to look it up by it's ID
50
50
  # or we'll insert a default TBD link.
@@ -200,20 +200,7 @@ module Inkcite
200
200
  # href matches the desired domain name.
201
201
  tag_domain = ctx[TAG_LINKS_DOMAIN]
202
202
  if tag_domain.blank? || href =~ /^https?:\/\/[^\/]*#{tag_domain}/
203
-
204
- # Prepend it with a question mark or an ampersand depending on the current
205
- # state of the lin.
206
- stag = href.include?('?') ? '&' : '?'
207
- stag << replace_tag(tag, id, ctx)
208
-
209
- # Inject before the pound sign if present - otherwise, just tack it on
210
- # to the end of the href.
211
- if hash = href.index(POUND_SIGN)
212
- href[hash..0] = stag
213
- else
214
- href << stag
215
- end
216
-
203
+ Util::add_query_param(href, replace_tag(tag, id, ctx))
217
204
  end
218
205
 
219
206
  end
@@ -2,17 +2,18 @@ module Inkcite
2
2
  module Renderer
3
3
  class Responsive < Base
4
4
 
5
- BUTTON = 'button'
6
- DROP = 'drop'
7
- FILL = 'fill'
8
- FLUID = 'fluid'
9
- FLUID_DROP = 'fluid-drop'
10
- HIDE = 'hide'
11
- IMAGE = 'img'
12
- SHOW = 'show'
13
- SWITCH = 'switch'
14
- SWITCH_UP = 'switch-up'
15
- TOGGLE = 'toggle'
5
+ BUTTON = 'button'
6
+ DROP = 'drop'
7
+ FILL = 'fill'
8
+ FLUID = 'fluid'
9
+ FLUID_DROP = 'fluid-drop'
10
+ FLUID_STACK = 'fluid-stack'
11
+ HIDE = 'hide'
12
+ IMAGE = 'img'
13
+ SHOW = 'show'
14
+ SWITCH = 'switch'
15
+ SWITCH_UP = 'switch-up'
16
+ TOGGLE = 'toggle'
16
17
 
17
18
  # For elements that take on different background properties
18
19
  # when they go responsive
@@ -185,7 +186,14 @@ module Inkcite
185
186
  # Returns true if the mobile klass provided matches any of the
186
187
  # Fluid-Hybrid classes.
187
188
  def is_fluid? mobile
188
- mobile == FLUID || mobile == FLUID_DROP
189
+ mobile == FLUID || is_fluid_drop?(mobile)
190
+ end
191
+
192
+ # Returns true if the mobile klass provided matches any of the
193
+ # Fluid-Hybrid classes that result in a table's columns stacking
194
+ # vertically.
195
+ def is_fluid_drop? mobile
196
+ mobile == FLUID_DROP || mobile == FLUID_STACK
189
197
  end
190
198
 
191
199
  def mix_font element, opt, ctx, parent=nil
@@ -21,7 +21,7 @@ module Inkcite
21
21
  # If the table was declared as Fluid-Hybrid Drop, then there are some additional
22
22
  # elements that need to be closed before the regular row-table closure that
23
23
  # the Table helper normally produces.
24
- if open_mobile == FLUID_DROP
24
+ if is_fluid_drop?(open_mobile)
25
25
 
26
26
  # Close the interior conditional table for Outlook that contains the floating blocks.
27
27
  html << if_mso('</tr></table>')
@@ -50,7 +50,7 @@ module Inkcite
50
50
 
51
51
  # Check if fluid-drop has been specified. This will force a lot more HTML to
52
52
  # be produced for this table and its child TDs.
53
- is_fluid_drop = mobile == FLUID_DROP
53
+ is_fluid_drop = is_fluid_drop?(mobile)
54
54
 
55
55
  # Inherit base cell attributes - border, background color and image, etc.
56
56
  mix_all table, opt, ctx
@@ -129,7 +129,14 @@ module Inkcite
129
129
  #
130
130
  # The zero-size font addresses a rendering problem in Outlook:
131
131
  # https://css-tricks.com/fighting-the-space-between-inline-block-elements/
132
- html << Element.new('td', :style => { TEXT_ALIGN => :center, VERTICAL_ALIGN => opt[:valign], FONT_SIZE => 0 }).to_s
132
+ fluid_td = Element.new('td', :style => { TEXT_ALIGN => :center, VERTICAL_ALIGN => opt[:valign], FONT_SIZE => 0 })
133
+
134
+ # If fluid-stack is specified, then reverse the order of the columns to make
135
+ # the right-most orient to the top.
136
+ # http://webdesign.tutsplus.com/tutorials/creating-a-future-proof-responsive-email-without-media-queries--cms-23919
137
+ fluid_td[:dir] = :rtl if mobile == FLUID_STACK
138
+
139
+ html << fluid_td.to_s
133
140
 
134
141
  # Lastly, Outlook needs yet another conditional table that will be used
135
142
  # to contain the floating blocks. The TD elements are generated by
@@ -12,6 +12,7 @@ module Inkcite
12
12
  # Grab the attributes of the parent table so that the TD can inherit
13
13
  # specific values like padding, valign, responsiveness, etc.
14
14
  table_opt = ctx.parent_opts(:table)
15
+ table_mobile = table_opt[:mobile]
15
16
 
16
17
  # Check to see if the parent table was set to fluid-drop which causes
17
18
  # the table cells to be wrapped in <div> elements and floated to
@@ -19,7 +20,7 @@ module Inkcite
19
20
  #
20
21
  # Fluid-Hybrid TD courtesy of @moonstrips and our friends at Campaign Monitor
21
22
  # https://www.campaignmonitor.com/blog/email-marketing/2014/07/creating-a-centred-responsive-design-without-media-queries/
22
- is_fluid_drop = table_opt[:mobile] == FLUID_DROP
23
+ is_fluid_drop = is_fluid_drop?(table_mobile)
23
24
 
24
25
  if tag == CLOSE_TD
25
26
 
@@ -77,8 +78,8 @@ module Inkcite
77
78
  # Width must be specified for Fluid-Drop cells. Vertical-alignment is
78
79
  # also important but should have been preset by the Table Helper if it
79
80
  # was omitted by the designer.
80
- ctx.error("Width is a required attribute when #{FLUID_DROP} is specified", opt) unless width > 0
81
- ctx.error("Vertical alignment should be specified when #{FLUID_DROP} is specified", opt) if valign.blank?
81
+ ctx.error("Width is a required attribute when #{table_mobile} is specified", opt) unless width > 0
82
+ ctx.error("Vertical alignment should be specified when #{table_mobile} is specified", opt) if valign.blank?
82
83
 
83
84
  # Conditional Outlook cell to prevent the 100%-wide table within from
84
85
  # stretching beyond the max-width. Also, valign necessary to get float
@@ -101,55 +101,78 @@ module Inkcite
101
101
 
102
102
  puts "Uploading to #{host} ..."
103
103
 
104
- # Get a local handle on the litmus configuration.
105
- Net::SFTP.start(host, username, :password => password) do |sftp|
104
+ begin
106
105
 
107
- # Upload each version of the email.
108
- email.versions.each do |version|
106
+ # Get a local handle on the litmus configuration.
107
+ Net::SFTP.start(host, username, :password => password) do |sftp|
109
108
 
110
- view = email.view(:preview, :email, version)
109
+ # Upload each version of the email.
110
+ email.versions.each do |version|
111
111
 
112
- # Need to pass the upload path through the renderer to ensure
113
- # that embedded tags will be converted into data.
114
- remote_root = Inkcite::Renderer.render(path, view)
112
+ view = email.view(:preview, :email, version)
115
113
 
116
- # Recursively ensure that the full directory structure necessary for
117
- # the content and images is present.
118
- mkdir! sftp, remote_root
114
+ # Need to pass the upload path through the renderer to ensure
115
+ # that embedded tags will be converted into data.
116
+ remote_root = Inkcite::Renderer.render(path, view)
119
117
 
120
- # Upload the images to the remote directory. We use the last_remote_root
121
- # to ensure that we're not repeatedly uploading the same images over and
122
- # over when force is enabled -- but will re-upload images to distinct
123
- # remote roots.
124
- copy! sftp, local_images, remote_root, force && last_remote_root != remote_root
125
- last_remote_root = remote_root
118
+ # Recursively ensure that the full directory structure necessary for
119
+ # the content and images is present.
120
+ mkdir! sftp, remote_root
126
121
 
127
- # Check to see if we're creating an in-browser version of the email.
128
- next unless email.formats.include?(:browser)
122
+ # Upload the images to the remote directory. We use the last_remote_root
123
+ # to ensure that we're not repeatedly uploading the same images over and
124
+ # over when force is enabled -- but will re-upload images to distinct
125
+ # remote roots.
126
+ copy! sftp, local_images, remote_root, force && last_remote_root != remote_root
127
+ last_remote_root = remote_root
129
128
 
130
- browser_view = email.view(:preview, :browser, version)
129
+ # Check to see if we're creating an in-browser version of the email.
130
+ next unless email.formats.include?(:browser)
131
131
 
132
- # Check to see if there is a HTML version of this preview. Some emails
133
- # do not have a hosted version and so it is not necessary to upload the
134
- # HTML version of the email - but this is a bad practice.
135
- file_name = view.file_name
136
- next if file_name.blank?
132
+ browser_view = email.view(:preview, :browser, version)
137
133
 
138
- remote_file_name = File.join(remote_root, file_name)
139
- puts "Uploading #{remote_file_name}"
134
+ # Check to see if there is a HTML version of this preview. Some emails
135
+ # do not have a hosted version and so it is not necessary to upload the
136
+ # HTML version of the email - but this is a bad practice.
137
+ file_name = browser_view.file_name
138
+ next if file_name.blank?
140
139
 
141
- # We need to use StringIO to write the email to a buffer in order to upload
142
- # the email's content in binary so that its encoding is honored. SFTP defaults
143
- # to ASCII-8bit in non-binary mode, so it was blowing up on UTF-8 special
144
- # characters (e.g. "Mäkinen").
145
- # http://stackoverflow.com/questions/9439289/netsftp-transfer-mode-binary-vs-text
146
- io = StringIO.new(browser_view.render!)
147
- sftp.upload!(io, remote_file_name)
140
+ remote_file_name = File.join(remote_root, file_name)
141
+ puts "Uploading #{remote_file_name}"
142
+
143
+ # We need to use StringIO to write the email to a buffer in order to upload
144
+ # the email's content in binary so that its encoding is honored. SFTP defaults
145
+ # to ASCII-8bit in non-binary mode, so it was blowing up on UTF-8 special
146
+ # characters (e.g. "Mäkinen").
147
+ # http://stackoverflow.com/questions/9439289/netsftp-transfer-mode-binary-vs-text
148
+ io = StringIO.new(browser_view.render!)
149
+ sftp.upload!(io, remote_file_name)
150
+
151
+ end
148
152
 
149
153
  end
150
154
 
155
+ rescue SocketError => e
156
+ abort <<-USAGE.strip_heredoc
157
+
158
+ Oops! There was an unexpected error trying to upload to your
159
+ CDN or image host:
160
+
161
+ #{e.message}
162
+
163
+ Please check that the sftp section of config.yml is correct:
164
+
165
+ sftp:
166
+ host: '#{host}'
167
+ path: '#{path}'
168
+ username: '#{username}'
169
+ password: '#{password.gsub(/./, '*')}'
170
+
171
+ USAGE
172
+
151
173
  end
152
174
 
175
+
153
176
  # Timestamp to indicate we uploaded now
154
177
  email.set_meta :last_upload, Time.now.to_i
155
178
 
@@ -3,6 +3,22 @@
3
3
  module Inkcite
4
4
  module Util
5
5
 
6
+ def self.add_query_param href, value
7
+
8
+ # Start with either a question mark or an ampersand depending on
9
+ # whether or not there is already a question mark in the URI.
10
+ param = href.include?('?') ? '&' : '?'
11
+ param << value.to_s
12
+
13
+ if hash_position = href.index('#')
14
+ href[hash_position..0] = param
15
+ else
16
+ href << param
17
+ end
18
+
19
+ href
20
+ end
21
+
6
22
  def self.brightness_value color
7
23
  color.nil? ? 0 : (color.gsub('#', '').scan(/../).map { |c| c.hex }).inject { |sum, c| sum + c }
8
24
  end
@@ -45,6 +61,10 @@ module Inkcite
45
61
 
46
62
  end
47
63
 
64
+ def self.is_fully_qualified? href
65
+ href.include?('//')
66
+ end
67
+
48
68
  def self.last_modified file
49
69
  file && File.exists?(file) ? File.mtime(file).to_i : 0
50
70
  end
@@ -1,3 +1,3 @@
1
1
  module Inkcite
2
- VERSION = "1.8.0"
2
+ VERSION = "1.9.0"
3
3
  end
@@ -216,29 +216,47 @@ module Inkcite
216
216
  fn
217
217
  end
218
218
 
219
+ # Returns the fully-qualified URL to the designated image (e.g. logo.gif)
220
+ # appropriate for the current rendering environment. In development
221
+ # mode, local will have either images/ or images-optim/ prepended on them
222
+ # depending on the status of image optimization.
223
+ #
224
+ # For non-development builds, fully-qualified URLs may be returned depending
225
+ # on the state of the config.yml and how image-host attributes have been
226
+ # configured.
227
+ #
228
+ # If a fully-qualified URL is provided, the URL will be returned with the
229
+ # possible addition of the cache-breaker tag.
219
230
  def image_url src
220
231
 
221
232
  src_url = ''
222
233
 
223
- # Prepend the image host onto the src if one is specified in the properties.
224
- # During local development, images are always expected in an images/ subdirectory.
225
- image_host = if development?
226
- (@email.optimize_images?? Minifier::IMAGE_CACHE : Email::IMAGES) + '/'
234
+ if Util.is_fully_qualified?(src)
235
+ src_url << src
236
+
227
237
  else
228
238
 
229
- # Use the image host defined in config.yml or, out-of-the-box refer to images/
230
- # in the build directory.
231
- self[Email::IMAGE_HOST] || (Email::IMAGES + '/')
239
+ # Prepend the image host onto the src if one is specified in the properties.
240
+ # During local development, images are always expected in an images/ subdirectory.
241
+ image_host = if development?
242
+ (@email.optimize_images?? Minifier::IMAGE_CACHE : Email::IMAGES) + '/'
243
+ else
244
+
245
+ # Use the image host defined in config.yml or, out-of-the-box refer to images/
246
+ # in the build directory.
247
+ self[Email::IMAGE_HOST] || (Email::IMAGES + '/')
232
248
 
233
- end
249
+ end
250
+
251
+ src_url << image_host unless image_host.blank?
234
252
 
235
- src_url << image_host unless image_host.blank?
253
+ # Add the source of the image.
254
+ src_url << src
236
255
 
237
- # Add the source of the image.
238
- src_url << src
256
+ end
239
257
 
240
258
  # Cache-bust the image if the caller is expecting it to be there.
241
- src_url << "?#{Time.now.to_i}" if !production? && is_enabled?(Email::CACHE_BUST)
259
+ Util::add_query_param(src_url, Time.now.to_i) if !production? && is_enabled?(Email::CACHE_BUST)
242
260
 
243
261
  # Transpose any embedded tags into actual values.
244
262
  Renderer.render(src_url, self)
@@ -548,10 +566,6 @@ module Inkcite
548
566
  # Empty hash used when there is no environment or format-specific configuration
549
567
  EMPTY_HASH = {}
550
568
 
551
- # Name of the property holding the email field used to ensure that an unsubscribe has
552
- # been placed into emails.
553
- EMAIL_MERGE_TAG = :'email-merge-tag'
554
-
555
569
  # Used when there is no subject or title for this email.
556
570
  UNTITLED_EMAIL = 'Untitled Email'
557
571
 
@@ -20,12 +20,12 @@ describe Inkcite::Renderer::Image do
20
20
 
21
21
  it 'substitutes a placeholder for a missing image of sufficient size' do
22
22
  @view.config[Inkcite::Email::IMAGE_PLACEHOLDERS] = true
23
- Inkcite::Renderer.render('{img src=missing.jpg height=50 width=100}', @view).must_equal('<img border=0 height=50 src="http://placehold.it/100x50.jpg" style="display:block" width=100>')
23
+ Inkcite::Renderer.render('{img src=missing.jpg height=50 width=100}', @view).must_equal('<img border=0 height=50 src="http://placehold.it/100x50.jpg?text=missing.jpg%0A(100%C3%9750)" style="display:block" width=100>')
24
24
  end
25
25
 
26
26
  it 'has configurable placeholder text' do
27
27
  @view.config[Inkcite::Email::IMAGE_PLACEHOLDERS] = true
28
- Inkcite::Renderer.render('{img src=missing.jpg height=50 width=100 fpo="F P O"}', @view).must_equal('<img border=0 height=50 src="http://placehold.it/100x50.jpg&text=F%20P%20O" style="display:block" width=100>')
28
+ Inkcite::Renderer.render('{img src=missing.jpg height=50 width=100 fpo="F P O"}', @view).must_equal('<img border=0 height=50 src="http://placehold.it/100x50.jpg?text=F%20P%20O%0A(100%C3%9750)" style="display:block" width=100>')
29
29
  end
30
30
 
31
31
  it 'does not substitute placeholders for small images' do
@@ -33,6 +33,10 @@ describe Inkcite::Renderer::Image do
33
33
  Inkcite::Renderer.render('{img src=missing.jpg height=5 width=15}', @view).must_equal('<img border=0 height=5 src="missing.jpg" style="display:block" width=15>')
34
34
  end
35
35
 
36
+ it 'does not alter externally referenced images' do
37
+ Inkcite::Renderer.render('{img src=://i.imgur.com/YJOX1PC.png height=5 width=15}', @view).must_equal('<img border=0 height=5 src="://i.imgur.com/YJOX1PC.png" style="display:block" width=15>')
38
+ end
39
+
36
40
  it 'has configurable dimensions' do
37
41
  Inkcite::Renderer.render('{img src=inkcite.jpg height=73 width=73}', @view).must_equal('<img border=0 height=73 src="images/inkcite.jpg" style="display:block" width=73>')
38
42
  end
@@ -34,6 +34,12 @@ describe Inkcite::Renderer::Link do
34
34
  Inkcite::Renderer.render('{a href="#news"}Latest News{/a}', @view).must_equal('<a href="#news" style="color:#0099cc;text-decoration:none">Latest News</a>')
35
35
  end
36
36
 
37
+ it 'tags a reused link once and only once' do
38
+ @view.config[:'tag-links'] = "tag=inkcite|{id}"
39
+ Inkcite::Renderer.render('{a id="litmus" href="http://litmus.com"}Test Emails Here{/a}{a id="litmus"}Also Here{/a}', @view).must_equal('<a href="http://litmus.com?tag=inkcite|litmus" style="color:#0099cc;text-decoration:none" target=_blank>Test Emails Here</a><a href="http://litmus.com?tag=inkcite|litmus" style="color:#0099cc;text-decoration:none" target=_blank>Also Here</a>')
40
+
41
+ end
42
+
37
43
  it 'raises a warning and generates an ID if one is not present' do
38
44
  Inkcite::Renderer.render('{a href="http://inkceptional.com"}Click Here{/a}', @view).must_equal('<a href="http://inkceptional.com" style="color:#0099cc;text-decoration:none" target=_blank>Click Here</a>')
39
45
  @view.errors.must_include('Link missing ID (line 0) [href=http://inkceptional.com]')
@@ -17,7 +17,7 @@ describe Inkcite::Renderer::MobileImage do
17
17
  it 'substitutes a placeholder for a missing image of sufficient size' do
18
18
  @view.config[Inkcite::Email::IMAGE_PLACEHOLDERS] = true
19
19
  Inkcite::Renderer.render('{mobile-img src=inkcite-mobile.jpg height=100 width=300}{/mobile-img}', @view).must_equal('<span class="i01 img"></span>')
20
- @view.media_query.find_by_klass('i01').to_css.must_equal('span[class~="i01"] { background-image:url("http://placehold.it/300x100.jpg");height:100px;width:300px }')
20
+ @view.media_query.find_by_klass('i01').to_css.must_equal('span[class~="i01"] { background-image:url("http://placehold.it/300x100.jpg?text=inkcite-mobile.jpg%0A(300%C3%97100)");height:100px;width:300px }')
21
21
  end
22
22
 
23
23
  it 'hides any images it wraps' do
@@ -51,4 +51,16 @@ describe Inkcite::Renderer::Table do
51
51
  Inkcite::Renderer.render(markup, @view).must_equal(%Q(<!--[if mso]><table border=0 cellpadding=0 cellspacing=0 width=600><tr><td><![endif]--><table bgcolor=#009900 border=0 cellpadding=0 cellspacing=0 style="border:5px solid #f0f;max-width:600px" width=100%><tr><td style="font-size:0;text-align:center;vertical-align:middle"><!--[if mso]><table align=center border=0 cellpadding=0 cellspacing=0 width=100%><tr><![endif]--><!--[if mso]><td valign=top width=195><![endif]--><div class="fill" style="display:inline-block;vertical-align:top;width:195px"><table border=0 cellpadding=15 cellspacing=0 width=100%><tr><td align=left bgcolor=#000099 style="color:#ffffff;font-size:25px;padding:15px;text-align:left" valign=top>left</td></tr></table></div><!--[if mso]></td><![endif]--><!--[if mso]><td valign=middle width=195><![endif]--><div class="fill" style="display:inline-block;vertical-align:middle;width:195px"><table border=0 cellpadding=15 cellspacing=0 width=100%><tr><td align=center style="font-size:25px;padding:15px" valign=middle>centered two-line</td></tr></table></div><!--[if mso]></td><![endif]--><!--[if mso]><td valign=middle width=195><![endif]--><div class="fill" style="display:inline-block;vertical-align:middle;width:195px"><table border=0 cellpadding=15 cellspacing=0 width=100%><tr><td align=right bgcolor=#990000 style="color:#ffffff;font-size:30px;padding:15px" valign=middle>right<br>three<br>lines</td></tr></table></div><!--[if mso]></td><![endif]--><!--[if mso]></tr></table><![endif]--></td></tr></table><!--[if mso]></td></tr></table><![endif]-->))
52
52
  end
53
53
 
54
+ it 'supports fluid-stack desktop and style' do
55
+
56
+ markup = ''
57
+ markup << %Q({table font-size=25 bgcolor=#090 border="5px solid #f0f" padding=15 width=600 mobile="fluid-stack"})
58
+ markup << %Q({td width=195 bgcolor=#009 color=#fff valign=top}left{/td})
59
+ markup << %Q({td width=195 align=center}centered two-line{/td})
60
+ markup << %Q({td width=195 bgcolor=#900 color=#fff align=right font-size=30}right<br>three<br>lines{/td})
61
+ markup << %Q({/table})
62
+
63
+ Inkcite::Renderer.render(markup, @view).must_equal(%Q(<!--[if mso]><table border=0 cellpadding=0 cellspacing=0 width=600><tr><td><![endif]--><table bgcolor=#009900 border=0 cellpadding=0 cellspacing=0 style="border:5px solid #f0f;max-width:600px" width=100%><tr><td dir=rtl style="font-size:0;text-align:center;vertical-align:middle"><!--[if mso]><table align=center border=0 cellpadding=0 cellspacing=0 width=100%><tr><![endif]--><!--[if mso]><td valign=top width=195><![endif]--><div class="fill" style="display:inline-block;vertical-align:top;width:195px"><table border=0 cellpadding=15 cellspacing=0 width=100%><tr><td align=left bgcolor=#000099 style="color:#ffffff;font-size:25px;padding:15px;text-align:left" valign=top>left</td></tr></table></div><!--[if mso]></td><![endif]--><!--[if mso]><td valign=middle width=195><![endif]--><div class="fill" style="display:inline-block;vertical-align:middle;width:195px"><table border=0 cellpadding=15 cellspacing=0 width=100%><tr><td align=center style="font-size:25px;padding:15px" valign=middle>centered two-line</td></tr></table></div><!--[if mso]></td><![endif]--><!--[if mso]><td valign=middle width=195><![endif]--><div class="fill" style="display:inline-block;vertical-align:middle;width:195px"><table border=0 cellpadding=15 cellspacing=0 width=100%><tr><td align=right bgcolor=#990000 style="color:#ffffff;font-size:30px;padding:15px" valign=middle>right<br>three<br>lines</td></tr></table></div><!--[if mso]></td><![endif]--><!--[if mso]></tr></table><![endif]--></td></tr></table><!--[if mso]></td></tr></table><![endif]-->))
64
+ end
65
+
54
66
  end
@@ -103,6 +103,10 @@ describe Inkcite::Renderer::Td do
103
103
  Inkcite::Renderer.render('{td background=floor.jpg background-position=bottom}', @view).must_equal('<td style="background:url(images/floor.jpg) bottom no-repeat">')
104
104
  end
105
105
 
106
+ it 'can have an externally hosted background image' do
107
+ Inkcite::Renderer.render('{td background=//i.imgur.com/YJOX1PC.png background-position=bottom}', @view).must_equal('<td style="background:url(//i.imgur.com/YJOX1PC.png) bottom no-repeat">')
108
+ end
109
+
106
110
  it 'can have a background image on mobile' do
107
111
  Inkcite::Renderer.render('{td mobile-background-image=wall.jpg mobile-background-position=right mobile-background-repeat=repeat-y}', @view).must_equal('<td class="m1">')
108
112
  @view.media_query.find_by_klass('m1').to_css.must_equal('td[class~="m1"] { background:url(images/wall.jpg) right repeat-y }')
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: inkcite
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.8.0
4
+ version: 1.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeffrey D. Hoffman
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2015-11-14 00:00:00.000000000 Z
11
+ date: 2015-11-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport