inkcite 1.13.0 → 1.14.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: db3a1196fe9bcc49e718925d27731a469807acdc
4
- data.tar.gz: 4dfc1aa8998ec37e5ed03a81facaf7f75ac6bfc9
3
+ metadata.gz: a5968f3e718b373eb3740d89dd019165130776a2
4
+ data.tar.gz: e961a846c6e2d0664a66a1ae5abc9ed791db34dd
5
5
  SHA512:
6
- metadata.gz: e1e085b9a0668c30f9c6bd968f962caa42205b45702c4c9cba5893bb71ea1fe0b63f6e85c90e767912c42640f36f3a94ed11b54078d189323fc1886a5d37ab7c
7
- data.tar.gz: 92166f271e19bbcc7058136e2d77a441179a341ef8107d19c24a0f15f8ddc883c319d5410a50edfecfdac89180b1d518dd5270a1614fd09846f8f3145c3d08c6
6
+ metadata.gz: 068fe33972b2d0f91a9c8be212ac3781392860d518aaf5277104cb3af1e1491766e2f3a5da47bed778bea41273bbd1fba3335c8f0d026845e16d29da85e06311
7
+ data.tar.gz: 4f39b31b971373bb1beb2ab1c6689a53bdd73a0e75d3def5bc2eeaae61e83449e7a43581bce9375d2434c2c7e6178a4b912b098459a304080acb11be46664595
data/README.md CHANGED
@@ -112,7 +112,7 @@ developer questions in a timely manner.
112
112
 
113
113
  ## License
114
114
 
115
- Copyright (c) 2014-2015 Jeffrey D. Hoffman. MIT Licensed, see [LICENSE] for
115
+ Copyright (c) 2014-2017 Jeffrey D. Hoffman. MIT Licensed, see [LICENSE] for
116
116
  details.
117
117
 
118
118
  [Middleman]: http://middlemanapp.com
@@ -33,6 +33,7 @@ Gem::Specification.new do |spec|
33
33
  spec.add_dependency 'htmlbeautifier'
34
34
  spec.add_dependency 'image_optim'
35
35
  spec.add_dependency 'image_optim_pack'
36
+ spec.add_dependency 'kraken-io'
36
37
  spec.add_dependency 'listen'
37
38
  spec.add_dependency 'litmus'
38
39
  spec.add_dependency 'mail'
@@ -23,13 +23,13 @@ require 'active_support/core_ext/string/inflections'
23
23
  require 'active_support/core_ext/string/starts_ends_with'
24
24
 
25
25
  require 'inkcite/version'
26
+ require 'inkcite/facade'
26
27
  require 'inkcite/email'
27
28
  require 'inkcite/util'
28
29
  require 'inkcite/view'
29
30
  require 'inkcite/minifier'
30
31
  require 'inkcite/parser'
31
32
  require 'inkcite/renderer'
32
- require 'inkcite/animation'
33
33
 
34
34
  module Inkcite
35
35
 
@@ -53,7 +53,8 @@ module Inkcite
53
53
  # InkciteApp to server the email as the root index page.
54
54
  app = Rack::Builder.new do
55
55
  use Rack::LiveReload
56
- use Rack::Static, :urls => %w( /images /images-optim ), :root => '.'
56
+ use Rack::Static, :urls => %w( /images/ ), :root => '.'
57
+ use OptimizedImage, :email => email, :urls => %w( /images-optim/ ), :root => '.'
57
58
  run InkciteApp.new(email, opts)
58
59
  end
59
60
 
@@ -88,6 +89,35 @@ module Inkcite
88
89
 
89
90
  private
90
91
 
92
+ # Extends Rack::Static to provide dynamic image
93
+ # minification on demand. When an image is requested
94
+ # from the images-optim directory, compression is
95
+ # performed on the desired image if necessary and then
96
+ # the optimized image is returned.
97
+ class OptimizedImage < Rack::Static
98
+
99
+ def initialize app, opts
100
+ @email = opts[:email]
101
+ super
102
+ end
103
+
104
+ def call env
105
+
106
+ # e.g. images-optim/my-image.jpg
107
+ path = env['PATH_INFO']
108
+
109
+ # Minify the image if the source version in images/ is newer
110
+ # or if the configuration file controlling optimization has
111
+ # been updated since the last time the image was requested.
112
+ Minifier.image(@email, File.basename(path), false) if can_serve(path)
113
+
114
+ # Let the super method handle the actual serving of the image.
115
+ super
116
+
117
+ end
118
+
119
+ end
120
+
91
121
  class InkciteApp
92
122
 
93
123
  def initialize email, opts
@@ -123,11 +153,6 @@ module Inkcite
123
153
  puts ''
124
154
  puts "#{ts} Rendering your email [environment=#{environment}, format=#{format}, version=#{version || 'default'}]"
125
155
 
126
- # Before the rendering takes place, trigger image optimization of any
127
- # new or updated images. The {image} tag takes care of injecting the
128
- # right path (optimized or not) depending on which version is needed.
129
- @email.optimize_images
130
-
131
156
  view = @email.view(environment, format, version)
132
157
 
133
158
  html = view.render!
@@ -0,0 +1,6 @@
1
+ # Manages the facade classes that make it easier to
2
+ # work with HTML elements, CSS styles and animations.
3
+ require_relative 'facade/animation'
4
+ require_relative 'facade/element'
5
+ require_relative 'facade/keyframe'
6
+ require_relative 'facade/style'
@@ -1,71 +1,27 @@
1
1
  module Inkcite
2
2
  class Animation
3
3
 
4
- class Keyframe
4
+ # A collection of animations assigned to a single element.
5
+ class Composite
5
6
 
6
- attr_reader :percent, :style
7
-
8
- def initialize percent, ctx, styles={}
9
-
10
- # Animation percents are always rounded to the nearest whole number.
11
- @percent = percent.round(0)
12
-
13
- # Instantiate a new Style for this percentage.
14
- @style = Inkcite::Renderer::Style.new("#{@percent}%", ctx, styles)
15
-
16
- end
17
-
18
- def [] key
19
- @style[key]
20
- end
21
-
22
- def []= key, val
23
- @style[key] = val
24
- end
25
-
26
- # For style chaining - e.g. keyframe.add(:key1, 'val').add(:key)
27
- def add key, val
28
- @style[key] = val
29
- self
7
+ def initialize
8
+ @animations = []
30
9
  end
31
10
 
32
- # Appends a value to an existing key
33
- def append key, val
34
-
35
- @style[key] ||= ''
36
- @style[key] << ' ' unless @style[key].blank?
37
- @style[key] << val
38
-
39
- end
40
-
41
- def add_with_prefixes key, val, ctx
42
-
43
- ctx.prefixes.each do |prefix|
44
- _key = "#{prefix}#{key}".to_sym
45
- self[_key] = val
46
- end
47
-
48
- self
11
+ def << animation
12
+ @animations << animation
49
13
  end
50
14
 
51
- def to_css prefix
52
- @style.to_css(prefix)
15
+ def to_keyframe_css
16
+ @animations.collect(&:to_keyframe_css).join("\n")
53
17
  end
54
18
 
55
- private
56
-
57
- # Creates a copy of the array of styles with the appropriate
58
- # properties (e.g. transform) prefixed.
59
- def get_prefixed_styles prefix
60
-
61
- _styles = {}
19
+ def to_s
62
20
 
63
- @styles.each_pair do |key, val|
64
- key = "#{prefix}#{key}".to_sym if Inkcite::Renderer::Style.needs_prefixing?(key)
65
- _styles[key] = val
66
- end
21
+ # Render each of the animations in the collection and join them
22
+ # in a single, comma-delimited string.
23
+ @animations.collect(&:to_s).join(', ')
67
24
 
68
- _styles
69
25
  end
70
26
 
71
27
  end
@@ -76,7 +32,9 @@ module Inkcite
76
32
  # Timing functions
77
33
  LINEAR = 'linear'
78
34
  EASE = 'ease'
35
+ EASE_IN = 'ease-in'
79
36
  EASE_IN_OUT = 'ease-in-out'
37
+ EASE_OUT = 'ease-out'
80
38
 
81
39
  # Animation name, view context and array of keyframes
82
40
  attr_reader :name, :ctx
@@ -106,6 +64,11 @@ module Inkcite
106
64
  keyframe
107
65
  end
108
66
 
67
+ # Returns true if this animation is blank - e.g. it has no keyframes.
68
+ def blank?
69
+ @keyframes.blank?
70
+ end
71
+
109
72
  def to_keyframe_css
110
73
 
111
74
  css = ''
@@ -0,0 +1,83 @@
1
+ module Inkcite
2
+ class Animation
3
+ class Keyframe
4
+
5
+ attr_reader :percent, :style
6
+
7
+ # Ending percentage the animation stays at this keyframe. For
8
+ # example, a keyframe that starts at 20% and has a duration
9
+ # of 19.9% would render as 25%, 39.9% { ... }
10
+ attr_accessor :duration
11
+
12
+ def initialize percent, ctx, styles={}
13
+
14
+ # Animation percents are always rounded to the nearest whole number.
15
+ @percent = percent.round(0)
16
+ @duration = 0
17
+
18
+ # Instantiate a new Style for this percentage.
19
+ @style = Inkcite::Renderer::Style.new(nil, ctx, styles)
20
+
21
+ end
22
+
23
+ def [] key
24
+ @style[key]
25
+ end
26
+
27
+ def []= key, val
28
+ @style[key] = val
29
+ end
30
+
31
+ # For style chaining - e.g. keyframe.add(:key1, 'val').add(:key)
32
+ def add key, val
33
+ @style[key] = val
34
+ self
35
+ end
36
+
37
+ # Appends a value to an existing key
38
+ def append key, val
39
+
40
+ @style[key] ||= ''
41
+ @style[key] << ' ' unless @style[key].blank?
42
+ @style[key] << val
43
+
44
+ end
45
+
46
+ def add_with_prefixes key, val, ctx
47
+
48
+ ctx.prefixes.each do |prefix|
49
+ _key = "#{prefix}#{key}".to_sym
50
+ self[_key] = val
51
+ end
52
+
53
+ self
54
+ end
55
+
56
+ def to_css prefix
57
+ css = "#{@percent}%"
58
+ css << ", #{@percent + @duration.to_f}%" if @duration > 0
59
+ css << ' { '
60
+ css << @style.to_inline_css(prefix)
61
+ css << ' }'
62
+ css
63
+ end
64
+
65
+ private
66
+
67
+ # Creates a copy of the array of styles with the appropriate
68
+ # properties (e.g. transform) prefixed.
69
+ def get_prefixed_styles prefix
70
+
71
+ _styles = {}
72
+
73
+ @styles.each_pair do |key, val|
74
+ key = "#{prefix}#{key}".to_sym if Inkcite::Renderer::Style.needs_prefixing?(key)
75
+ _styles[key] = val
76
+ end
77
+
78
+ _styles
79
+ end
80
+
81
+ end
82
+ end
83
+ end
@@ -18,6 +18,10 @@ module Inkcite
18
18
  @styles[key] = val
19
19
  end
20
20
 
21
+ def blank?
22
+ @styles.blank?
23
+ end
24
+
21
25
  def to_css allowed_prefixes=nil
22
26
  "#{@name} { #{to_inline_css(allowed_prefixes)} }"
23
27
  end
@@ -2,7 +2,7 @@ module Inkcite
2
2
  class Minifier
3
3
 
4
4
  # Directory of optimized images
5
- IMAGE_CACHE = "images-optim"
5
+ IMAGE_CACHE = 'images-optim'
6
6
 
7
7
  # Maximum line length for CSS and HTML - lines exceeding this length cause
8
8
  # problems in certain email clients.
@@ -40,14 +40,31 @@ module Inkcite
40
40
  # a semicolon or close bracket.
41
41
  if ctx.email? && code.length > MAXIMUM_LINE_LENGTH
42
42
 
43
- # Position at which a line break will be inserted at.
44
- break_at = 0
43
+ # Last position at which a line break was be inserted at.
44
+ last_break_at = 0
45
45
 
46
46
  # Work through the code injecting line breaks until either no further
47
47
  # breakable characters are found or we've reached the end of the code.
48
- while break_at < code.length
49
- break_at = code.rindex(/[;}]/, break_at + MAXIMUM_LINE_LENGTH) + 1
50
- code.insert(break_at, "\n") if break_at && break_at < code.length
48
+ while last_break_at < code.length
49
+ break_at = code.rindex(/[ ,;{}]/, last_break_at + MAXIMUM_LINE_LENGTH)
50
+
51
+ # No further characters match (unlikely) or an unbroken string since
52
+ # the last time a break was injected. Either way, let's get out.
53
+ break if break_at.nil? || break_at <= last_break_at
54
+
55
+ # If we've found a space we can break at, do a direct replacement of the
56
+ # space with a new line. Otherwise, inject a new line one spot after
57
+ # the matching character.
58
+ if code[break_at] == ' '
59
+ code[break_at] = NEW_LINE
60
+
61
+ else
62
+ break_at += 1
63
+ code.insert(break_at, NEW_LINE)
64
+ break_at += 1
65
+ end
66
+
67
+ last_break_at = break_at
51
68
  end
52
69
 
53
70
  end
@@ -107,75 +124,79 @@ module Inkcite
107
124
 
108
125
  end
109
126
 
110
- def self.images email, force=false
127
+ def self.image email, img_name, force=false
111
128
 
112
- images_path = email.image_dir
113
- cache_path = email.project_file(IMAGE_CACHE)
129
+ # Original, unoptimized source image
130
+ source_img = File.join(email.image_dir, img_name)
114
131
 
115
- # Check to see if there is an image optim configuration file.
116
- config_path = email.project_file(IMAGE_OPTIM_CONFIG_YML)
117
- config_last_modified = Util.last_modified(config_path)
132
+ # Cached, optimized path for this image.
133
+ cache_path = email.project_file(IMAGE_CACHE)
134
+ cached_img = File.join(cache_path, File.basename(img_name))
118
135
 
119
- # If the image cache exists, we need to check to see if any images have been
120
- # removed since the last build.
121
- if File.exist?(cache_path)
136
+ # Full path to the local project's kraken config if it exists
137
+ kraken_config_path = email.project_file(KRAKEN_CONFIG_YML)
122
138
 
123
- # Get a list of the files in the cache that do not also exist in the
124
- # project's images/ directory.
125
- removed_images = Dir.entries(cache_path) - Dir.entries(images_path)
126
- unless removed_images.blank?
139
+ # This is the array of config files that will be searched to
140
+ # determine which algorithm to use to compress the images.
141
+ config_paths = [
142
+ kraken_config_path,
143
+ email.project_file(IMAGE_OPTIM_CONFIG_YML),
144
+ File.join(Inkcite.asset_path, 'init', IMAGE_OPTIM_CONFIG_YML)
145
+ ]
127
146
 
128
- # Convert the images to fully-qualified paths and then remove
129
- # those files from the cache
130
- removed_images = removed_images.collect { |img| File.join(cache_path, img) }
131
- FileUtils.rm(removed_images)
147
+ # Grab the first file that exists for this project.
148
+ config_path = config_paths.detect { |p| File.exist?(p) }
132
149
 
133
- end
150
+ unless force
134
151
 
135
- end
152
+ # Get the last-modified date of the image optimization config
153
+ # file - if that file is newer than the image, re-optimization
154
+ # is necessary because the settings have changed.
155
+ config_last_modified = Util.last_modified(config_path)
136
156
 
137
- # Check to see if there are new or updated images that need to be re-optimized.
138
- # Compare existing images against both the most recently cached version and
139
- # the timestamp of the config file.
140
- updated_images = Dir.glob(File.join(images_path, '*.*')).select do |img|
141
- cached_img = File.join(cache_path, File.basename(img))
157
+ # Get the last-modified date of the actual image. If the source
158
+ # image is newer than the cached version, we'll need to run it
159
+ # through optimization again, too.
142
160
  cache_last_modified = Util.last_modified(cached_img)
143
- force || config_last_modified > cache_last_modified || Util.last_modified(img) > cache_last_modified
144
- end
161
+ source_last_modified = Util.last_modified(source_img)
162
+
163
+ # Nothing to do unless the image in the cache is older than the
164
+ # source or the config file.
165
+ return unless config_last_modified > cache_last_modified || source_last_modified > cache_last_modified
145
166
 
146
- # Return unless there is something to compress
147
- return if updated_images.blank?
167
+ end
148
168
 
169
+ # Make sure the image cache directory exists
149
170
  FileUtils.mkpath(cache_path)
150
171
 
151
- # Check to see if there is an image_optim.yml file in this directory that
152
- # overrides the default settings.
153
- image_optim_opts = if config_last_modified > 0
154
- {
155
- :config_paths => [IMAGE_OPTIM_CONFIG_YML]
156
- }
172
+ # Read the image compression configuration settings
173
+ config = Util::read_yml(config_path, :fail_if_not_exists => false)
174
+
175
+ if config_path == kraken_config_path
176
+ minify_with_kraken_io email, config, source_img, cached_img
177
+
157
178
  else
158
- {
159
- :allow_lossy => true,
160
- :gifsicle => { :level => 3 },
161
- :jpegoptim => { :max_quality => 50 },
162
- :jpegrecompress => { :quality => 1 },
163
- :pngout => false,
164
- :svgo => false
165
- }
166
- end
167
179
 
168
- image_optim = ImageOptim.new(image_optim_opts)
180
+ # Default image optimization uses built-in ImageOptim
181
+ minify_with_image_optim email, config, source_img, cached_img
169
182
 
170
- # Copy all of the images that need updating into the temporary directory.
171
- # Specifically joining the images_path to the image to avoid Email's
172
- # image_path which may change it's directory if optimization is enabled.
173
- updated_images.each do |img|
174
- cached_img = File.join(cache_path, File.basename(img))
175
- FileUtils.cp(img, cached_img)
176
- image_optim.optimize_image!(cached_img)
177
183
  end
178
184
 
185
+ original_size = File.size(source_img)
186
+ compressed_size = File.size(cached_img)
187
+ percent_compressed = ((1.0 - (compressed_size / original_size.to_f)) * 100).round(1)
188
+ puts "Compressed #{img_name} #{percent_compressed}%"
189
+
190
+ end
191
+
192
+ def self.images email, force=false
193
+
194
+ images_path = email.image_dir
195
+
196
+ # Iterate through all of the images in the project and optimize them
197
+ # if necessary.
198
+ Dir.glob(File.join(images_path, '*.*')).each { |img| self.image(email, File.basename(img), force) }
199
+
179
200
  end
180
201
 
181
202
  def self.js code, ctx
@@ -188,11 +209,74 @@ module Inkcite
188
209
 
189
210
  private
190
211
 
212
+ def self.minify_with_image_optim email, config, source_img, cached_img
213
+
214
+ # Copy the image into the destination directory and then use Image Optim
215
+ # to optimize it in place.
216
+ FileUtils.cp(source_img, cached_img)
217
+ ImageOptim.new(config).optimize_image!(cached_img)
218
+
219
+ end
220
+
221
+ def self.minify_with_kraken_io email, config, source_img, cached_img
222
+
223
+ require 'kraken-io'
224
+ require 'open-uri'
225
+
226
+ # Initialize the Kraken API using the API key and secret defined in the
227
+ # config.yml file.
228
+ kraken = Kraken::API.new(
229
+ :api_key => config[:api_key],
230
+ :api_secret => config[:api_secret]
231
+ )
232
+
233
+ # As you might expect, Outlook doesn't support webp so it needs to be
234
+ # disabled by default. Otherwise, Kraken always compresses with webp.
235
+ kraken_opts = { :webp => false }
236
+
237
+ # Get the file format (e.g. gif) of the file being optimized.
238
+ source_fmt = File.extname(source_img).delete('.')
239
+
240
+ # True if the configuration file does not specifically exclude
241
+ # this format from being processed.
242
+ compress_this_fmt = config[source_fmt.to_sym] != false
243
+
244
+ # Typically, we're going to want lossy compression to minify the file
245
+ # but if the user has put lossy: false specifically in their config
246
+ # file, we'll disable that feature in Kraken too. Defaults to true.
247
+ kraken_opts[:lossy] = compress_this_fmt
248
+
249
+ # Send the quality metric to Kraken only if specified. Per their
250
+ # documentation, Kraken will attempt to guess the best quality to
251
+ # use but in my experience it errs on the side of higher quality
252
+ # whereas setting a quality factor around 50 produces a good
253
+ # balance of image detail and file size.
254
+ if compress_this_fmt
255
+ quality = config[:quality].to_i
256
+ kraken_opts[:quality] = quality if quality > 0 and quality <= 100
257
+ end
258
+
259
+ # Upload the image to Kraken which blocks by default until the image
260
+ # has been optimized.
261
+ data = kraken.upload(source_img, kraken_opts)
262
+ if data.success
263
+ File.write(cached_img, open(data.kraked_url).read, { :mode => 'wb' })
264
+ else
265
+ puts "Failed to optimize #{img_name}: #{data.message}"
266
+ end
267
+
268
+ end
269
+
191
270
  # Name of the Image Optim configuration yml file that can be
192
271
  # put in the project directory to explicitly control the image
193
272
  # optimization process.
194
273
  IMAGE_OPTIM_CONFIG_YML = 'image_optim.yml'
195
274
 
275
+ # Name of the Kraken configuration yml that, when present in
276
+ # the project directory and populated with an API key and secret
277
+ # causes Kraken.io paid image optimization service to be used.
278
+ KRAKEN_CONFIG_YML = 'kraken.yml'
279
+
196
280
  NEW_LINE = "\n"
197
281
 
198
282
  # Used to match inline styles that will be compressed when minifying