inkcite 1.14.0 → 1.15.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/assets/init/config.yml +2 -0
  3. data/assets/social/facebook.png +0 -0
  4. data/assets/social/instagram.png +0 -0
  5. data/assets/social/pintrest.png +0 -0
  6. data/assets/social/twitter.png +0 -0
  7. data/inkcite.gemspec +2 -2
  8. data/lib/inkcite.rb +1 -0
  9. data/lib/inkcite/cli/base.rb +32 -6
  10. data/lib/inkcite/cli/preview.rb +24 -28
  11. data/lib/inkcite/cli/server.rb +22 -19
  12. data/lib/inkcite/cli/test.rb +1 -1
  13. data/lib/inkcite/email.rb +8 -4
  14. data/lib/inkcite/facade/animation.rb +4 -0
  15. data/lib/inkcite/facade/keyframe.rb +26 -3
  16. data/lib/inkcite/image/base.rb +38 -0
  17. data/lib/inkcite/image/guetzli_minifier.rb +62 -0
  18. data/lib/inkcite/image/image_minifier.rb +143 -0
  19. data/lib/inkcite/image/image_optim_minifier.rb +90 -0
  20. data/lib/inkcite/image/mozjpeg_minifier.rb +92 -0
  21. data/lib/inkcite/mailer.rb +201 -112
  22. data/lib/inkcite/minifier.rb +2 -146
  23. data/lib/inkcite/post_processor.rb +13 -0
  24. data/lib/inkcite/renderer.rb +19 -0
  25. data/lib/inkcite/renderer/background.rb +53 -14
  26. data/lib/inkcite/renderer/base.rb +29 -15
  27. data/lib/inkcite/renderer/button.rb +1 -1
  28. data/lib/inkcite/renderer/carousel.rb +245 -0
  29. data/lib/inkcite/renderer/container_base.rb +10 -0
  30. data/lib/inkcite/renderer/div.rb +1 -3
  31. data/lib/inkcite/renderer/fireworks.rb +54 -40
  32. data/lib/inkcite/renderer/footnote.rb +22 -2
  33. data/lib/inkcite/renderer/image.rb +11 -0
  34. data/lib/inkcite/renderer/image_base.rb +3 -6
  35. data/lib/inkcite/renderer/in_browser.rb +4 -0
  36. data/lib/inkcite/renderer/link.rb +39 -12
  37. data/lib/inkcite/renderer/mobile_image.rb +1 -1
  38. data/lib/inkcite/renderer/responsive.rb +9 -1
  39. data/lib/inkcite/renderer/social.rb +31 -3
  40. data/lib/inkcite/renderer/special_effect.rb +22 -13
  41. data/lib/inkcite/renderer/sup.rb +32 -0
  42. data/lib/inkcite/renderer/table_base.rb +3 -0
  43. data/lib/inkcite/renderer/topic.rb +76 -0
  44. data/lib/inkcite/renderer/trademark.rb +47 -0
  45. data/lib/inkcite/renderer/video_preview.rb +3 -2
  46. data/lib/inkcite/uploader.rb +2 -3
  47. data/lib/inkcite/util.rb +51 -0
  48. data/lib/inkcite/version.rb +1 -1
  49. data/lib/inkcite/view.rb +140 -54
  50. data/lib/inkcite/view/context.rb +1 -31
  51. data/lib/inkcite/view/media_query.rb +6 -0
  52. data/test/animation_spec.rb +7 -0
  53. data/test/parser_spec.rb +1 -1
  54. data/test/renderer/background_spec.rb +16 -12
  55. data/test/renderer/div_spec.rb +11 -0
  56. data/test/renderer/footnote_spec.rb +5 -1
  57. data/test/renderer/image_spec.rb +51 -28
  58. data/test/renderer/link_spec.rb +20 -8
  59. data/test/renderer/lorem_spec.rb +2 -2
  60. data/test/renderer/mobile_image_spec.rb +6 -0
  61. data/test/renderer/mobile_style_spec.rb +3 -3
  62. data/test/renderer/redacted_spec.rb +2 -2
  63. data/test/renderer/social_spec.rb +6 -6
  64. data/test/renderer/table_spec.rb +4 -0
  65. data/test/renderer/topic_spec.rb +28 -0
  66. data/test/renderer/trademark_spec.rb +40 -0
  67. data/test/renderer/video_preview_spec.rb +1 -1
  68. data/test/test_helper.rb +14 -0
  69. data/test/view_spec.rb +4 -0
  70. metadata +26 -12
  71. data/assets/init/image_optim.yml +0 -37
@@ -1,9 +1,8 @@
1
+ require_relative 'image/image_minifier'
2
+
1
3
  module Inkcite
2
4
  class Minifier
3
5
 
4
- # Directory of optimized images
5
- IMAGE_CACHE = 'images-optim'
6
-
7
6
  # Maximum line length for CSS and HTML - lines exceeding this length cause
8
7
  # problems in certain email clients.
9
8
  MAXIMUM_LINE_LENGTH = 800
@@ -124,81 +123,6 @@ module Inkcite
124
123
 
125
124
  end
126
125
 
127
- def self.image email, img_name, force=false
128
-
129
- # Original, unoptimized source image
130
- source_img = File.join(email.image_dir, img_name)
131
-
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))
135
-
136
- # Full path to the local project's kraken config if it exists
137
- kraken_config_path = email.project_file(KRAKEN_CONFIG_YML)
138
-
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
- ]
146
-
147
- # Grab the first file that exists for this project.
148
- config_path = config_paths.detect { |p| File.exist?(p) }
149
-
150
- unless force
151
-
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)
156
-
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.
160
- cache_last_modified = Util.last_modified(cached_img)
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
166
-
167
- end
168
-
169
- # Make sure the image cache directory exists
170
- FileUtils.mkpath(cache_path)
171
-
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
-
178
- else
179
-
180
- # Default image optimization uses built-in ImageOptim
181
- minify_with_image_optim email, config, source_img, cached_img
182
-
183
- end
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
-
200
- end
201
-
202
126
  def self.js code, ctx
203
127
  minify?(ctx) ? js_compressor(ctx).compress(code) : code
204
128
  end
@@ -209,74 +133,6 @@ module Inkcite
209
133
 
210
134
  private
211
135
 
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
-
270
- # Name of the Image Optim configuration yml file that can be
271
- # put in the project directory to explicitly control the image
272
- # optimization process.
273
- IMAGE_OPTIM_CONFIG_YML = 'image_optim.yml'
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
-
280
136
  NEW_LINE = "\n"
281
137
 
282
138
  # Used to match inline styles that will be compressed when minifying
@@ -0,0 +1,13 @@
1
+ module Inkcite
2
+ module PostProcessor
3
+
4
+ def post_process html, ctx
5
+ raise 'Extending class must implement process(html, ctx)'
6
+ end
7
+
8
+ def self.run_all html, ctx
9
+ ctx.post_processors.inject(html) { |h, pp| pp.post_process(h, ctx) }
10
+ end
11
+
12
+ end
13
+ end
@@ -7,6 +7,7 @@ require_relative 'renderer/table_base'
7
7
 
8
8
  require_relative 'renderer/background'
9
9
  require_relative 'renderer/button'
10
+ require_relative 'renderer/carousel'
10
11
  require_relative 'renderer/div'
11
12
  require_relative 'renderer/fireworks'
12
13
  require_relative 'renderer/footnote'
@@ -30,8 +31,11 @@ require_relative 'renderer/snow'
30
31
  require_relative 'renderer/social'
31
32
  require_relative 'renderer/span'
32
33
  require_relative 'renderer/sparkle'
34
+ require_relative 'renderer/sup'
33
35
  require_relative 'renderer/table'
34
36
  require_relative 'renderer/td'
37
+ require_relative 'renderer/topic'
38
+ require_relative 'renderer/trademark'
35
39
  require_relative 'renderer/video_preview'
36
40
 
37
41
  module Inkcite
@@ -64,6 +68,9 @@ module Inkcite
64
68
 
65
69
  end
66
70
 
71
+ # Remove unicode line break characters
72
+ value.gsub!(/\u2028/, '')
73
+
67
74
  value
68
75
  end
69
76
 
@@ -128,6 +135,10 @@ module Inkcite
128
135
 
129
136
  Parser.each(str) do |tag|
130
137
 
138
+ # Record to the context the most recent tag being processed in case
139
+ # there are errors associated with it.
140
+ context.last_rendered_markup = tag
141
+
131
142
  # Split the string into the tag and it's attributes.
132
143
  name, opts = tag.split(SPACE, 2)
133
144
 
@@ -171,6 +182,8 @@ module Inkcite
171
182
  :a => Link.new,
172
183
  :background => Background.new,
173
184
  :button => Button.new,
185
+ :carousel => Carousel.new,
186
+ :'carousel-img' => Carousel::Image.new,
174
187
  :div => Div.new,
175
188
  :facebook => Social::Facebook.new,
176
189
  :fireworks => Fireworks.new,
@@ -180,6 +193,7 @@ module Inkcite
180
193
  :img => Image.new,
181
194
  :'in-browser' => InBrowser.new,
182
195
  :include => Partial.new,
196
+ :instagram => Social::Instagram.new,
183
197
  :like => Like.new,
184
198
  :litmus => LitmusAnalytics.new,
185
199
  :lorem => Lorem.new,
@@ -189,12 +203,17 @@ module Inkcite
189
203
  :'mobile-toggle-on' => MobileToggleOn.new,
190
204
  :pintrest => Social::Pintrest.new,
191
205
  :preheader => Preheader.new,
206
+ :r => Trademark.new('&reg;'),
192
207
  :redacted => Redacted.new,
193
208
  :snow => Snow.new,
194
209
  :span => Span.new,
195
210
  :sparkle => Sparkle.new,
211
+ :sup => Sup.new,
196
212
  :table => Table.new,
197
213
  :td => Td.new,
214
+ :tm => Trademark.new('&trade;'),
215
+ :'topic' => Topic.new,
216
+ :'topic-list' => TopicList.new,
198
217
  :twitter => Social::Twitter.new,
199
218
  :'video-preview' => VideoPreview.new
200
219
  }
@@ -14,8 +14,19 @@ module Inkcite
14
14
 
15
15
  html = ''
16
16
 
17
+
18
+ tag_stack = ctx.tag_stack(:background)
19
+
17
20
  if tag == '/background'
18
21
 
22
+ opening = tag_stack.pop
23
+ padding = opening[:padding].to_i
24
+
25
+ html << vs(padding) if padding > 0
26
+ html << '{/td}'
27
+ html << hs(padding) if padding > 0
28
+
29
+ html << '{/table}'
19
30
  html << '</div>'
20
31
 
21
32
  # If VML is enabled, then close the textbox and rect that were created
@@ -32,6 +43,8 @@ module Inkcite
32
43
 
33
44
  else
34
45
 
46
+ tag_stack << opt
47
+
35
48
  # Primary background image
36
49
  src = opt[:src]
37
50
 
@@ -45,7 +58,6 @@ module Inkcite
45
58
  fill_width = width.nil? || width == 'fill' || width == '100%' || width.to_i <= 0
46
59
 
47
60
  table = Element.new('table')
48
- table[:height] = height if height > 0
49
61
  table[:width] = (fill_width ? '100%' : width)
50
62
  table[:background] = quote(src) unless none?(src)
51
63
 
@@ -55,7 +67,7 @@ module Inkcite
55
67
  # might interfere with the display of the background (e.g. padding)
56
68
  TABLE_PASSTHRU_OPS.each do |key|
57
69
  val = opt[key]
58
- table[key] = quote(val) unless none?(val)
70
+ table[key] = quote(val) unless val.blank?
59
71
  end
60
72
 
61
73
  # Determine if a fallback background color has been defined.
@@ -66,13 +78,8 @@ module Inkcite
66
78
  bggradient = detect_bggradient(opt)
67
79
  table[:bggradient] = quote(bggradient) unless none?(bggradient)
68
80
 
69
- td = Element.new('td')
70
-
71
- valign = opt[:valign]
72
- td[:valign] = valign unless valign.blank?
73
-
74
81
  html << table.to_helper
75
- html << td.to_helper
82
+ html << '{td}'
76
83
 
77
84
  # VML is only added if it is enabled for the project.
78
85
  if ctx.vml_enabled?
@@ -117,16 +124,38 @@ module Inkcite
117
124
 
118
125
  end
119
126
 
120
- div = Element.new('div')
127
+ html << '<div>'
128
+
129
+ html << '{table width=100%}'
130
+
131
+ padding = opt[:padding].to_i
132
+
133
+ html << hs(padding) if padding > 0
134
+
135
+ inner_td = Element.new('td')
136
+
137
+ # Height needs to be set on the inner TD to ensure that valign works
138
+ # in Outlook - which doesn't honor table height but will honor td height.
139
+ inner_td[:height] = height if height > 0
140
+
141
+ valign = opt[:valign]
142
+ inner_td[:valign] = valign unless valign.blank?
143
+
144
+ TD_PASSTHRU_OPS.each do |key|
145
+ val = opt[key]
146
+ inner_td[key] = quote(val) unless val.blank?
147
+ end
121
148
 
122
149
  # Font family and other attributes get reset within the v:textbox so allow
123
150
  # the font series of attributes to be applied.
124
- mix_font div, opt, ctx
151
+ mix_font inner_td, opt, ctx
125
152
 
126
153
  # Text alignment within the div.
127
- mix_text_align div, opt, ctx
154
+ mix_text_align inner_td, opt, ctx
128
155
 
129
- html << div.to_s
156
+ html << inner_td.to_helper
157
+
158
+ html << vs(padding) if padding > 0
130
159
 
131
160
  end
132
161
 
@@ -135,8 +164,13 @@ module Inkcite
135
164
 
136
165
  private
137
166
 
138
- # The custom
139
- MOBILE_SRC = :'mobile-src'
167
+ def hs padding
168
+ Element.new('td', :width => padding, :mobile => 'hide').to_helper + '&nbsp;{/td}'
169
+ end
170
+
171
+ def vs padding
172
+ Element.new('div', :height => padding, LINE_HEIGHT => padding, FONT_SIZE => padding, :mobile => 'hide').to_helper + '&nbsp;{/div}'
173
+ end
140
174
 
141
175
  # These are the parameters that are passed directly from
142
176
  # the provided opt to the {table} rendered within the
@@ -148,6 +182,11 @@ module Inkcite
148
182
  MOBILE_SRC, MOBILE_BACKGROUND_SIZE, MOBILE_WIDTH
149
183
  ]
150
184
 
185
+ TD_PASSTHRU_OPS = [
186
+ :align, :color, :font, FONT_FAMILY, FONT_SIZE, FONT_WEIGHT, LETTER_SPACING, LINE_HEIGHT,
187
+ MOBILE_TEXT_ALIGN, :shadow, TEXT_ALIGN, TEXT_SHADOW, TEXT_SHADOW_BLUR, TEXT_SHADOW_OFFSET
188
+ ]
189
+
151
190
  end
152
191
  end
153
192
  end
@@ -11,6 +11,7 @@ module Inkcite
11
11
  BACKGROUND_SIZE = :'background-size'
12
12
  BORDER_BOTTOM = :'border-bottom'
13
13
  BORDER_COLLAPSE = :'border-collapse'
14
+ BORDER_COLOR = :'border-color'
14
15
  BORDER_LEFT = :'border-left'
15
16
  BORDER_RADIUS = :'border-radius'
16
17
  BORDER_RIGHT = :'border-right'
@@ -28,6 +29,7 @@ module Inkcite
28
29
  MARGIN_LEFT = :'margin-left'
29
30
  MARGIN_RIGHT = :'margin-right'
30
31
  MARGIN_TOP = :'margin-top'
32
+ MAX_HEIGHT = :'max-height'
31
33
  MAX_WIDTH = :'max-width'
32
34
  PADDING_X = :'padding-x'
33
35
  PADDING_Y = :'padding-y'
@@ -40,6 +42,10 @@ module Inkcite
40
42
  WEBKIT_ANIMATION = :'-webkit-animation'
41
43
  WHITE_SPACE = :'white-space'
42
44
 
45
+ # Name of the property that allows an outlook-specific src to be specified
46
+ # for an image.
47
+ OUTLOOK_SRC = :'outlook-src'
48
+
43
49
  # CSS direction suffixes including nil/empty for convenience.
44
50
  DIRECTIONS = [ nil, :top, :right, :bottom, :left]
45
51
 
@@ -122,26 +128,34 @@ module Inkcite
122
128
  # Set the background color if the element has one.
123
129
  element.style[BACKGROUND_COLOR] = bgcolor if bgcolor
124
130
 
131
+ # Automatically include background gradient support when
132
+ # mixing in background color.
133
+ mix_background_gradient element, opt, ctx
134
+
135
+ end
136
+
137
+ def mix_background_gradient element, opt, ctx
138
+
125
139
  # Background gradient support
126
140
  bggradient = detect_bggradient(opt)
127
- unless none?(bggradient)
128
-
129
- # As a shortcut a gradient can be specified simply by designating
130
- # both a bgcolor and the gradient color - this will insert a radial
131
- # gradient automatically.
132
- if bggradient.start_with?('#')
141
+ return if none?(bggradient)
133
142
 
134
- # If a bgcolor is provided, the gradient goes bgcolor -> bggradient.
135
- # Otherwise, it goes bggradient->darker(bggradient)
136
- center_color = bgcolor ? bgcolor : bggradient
137
- outer_color = bgcolor ? bggradient : Util.darken(bggradient)
143
+ # As a shortcut a gradient can be specified simply by designating
144
+ # both a bgcolor and the gradient color - this will insert a radial
145
+ # gradient automatically.
146
+ if bggradient.start_with?('#')
138
147
 
139
- bggradient = %Q(radial-gradient(circle at center, #{center_color}, #{outer_color}))
140
- end
148
+ # If a bgcolor is provided, the gradient goes bgcolor -> bggradient.
149
+ # Otherwise, it goes bggradient->darker(bggradient)
150
+ bgcolor = detect_bgcolor(opt)
151
+ center_color = bgcolor ? bgcolor : bggradient
152
+ outer_color = bgcolor ? bggradient : Util.darken(bggradient)
141
153
 
142
- element.style[BACKGROUND_IMAGE] = bggradient
154
+ bggradient = %Q(radial-gradient(circle at center, #{center_color}, #{outer_color}))
143
155
  end
144
156
 
157
+ element.style[BACKGROUND_IMAGE] = bggradient
158
+
145
159
  end
146
160
 
147
161
  def mix_border element, opt, ctx
@@ -213,10 +227,10 @@ module Inkcite
213
227
  font
214
228
  end
215
229
 
216
- def mix_margins element, opt, ctx
230
+ def mix_margins element, opt, ctx, outlookCompatible=true
217
231
 
218
232
  # Outlook supports Margin, not margin.
219
- mix_directional element, element.style, opt, ctx, :margin, :Margin, true
233
+ mix_directional element, element.style, opt, ctx, :margin, outlookCompatible ? :Margin : :margin, true
220
234
 
221
235
  end
222
236