inkcite 1.13.0 → 1.14.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: 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