inkcite 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml 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