inkcite 1.2.0 → 1.6.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,10 +1,11 @@
1
+ require 'image_optim'
1
2
  require 'yui/compressor'
2
3
 
3
4
  module Inkcite
4
5
  class Minifier
5
6
 
6
7
  # Directory of optimized images
7
- IMAGE_CACHE = ".images"
8
+ IMAGE_CACHE = "images-optim"
8
9
 
9
10
  def self.css code, ctx
10
11
  minify?(ctx) ? css_compressor(ctx).compress(code) : code
@@ -60,15 +61,15 @@ module Inkcite
60
61
 
61
62
  end
62
63
 
63
- def self.images email
64
-
65
- image_optim_path = '/Applications/ImageOptim.app/Contents/MacOS/ImageOptim'
66
- image_optim = File.exists?(image_optim_path)
67
- abort "Can't find ImageOptim (#{image_optim_path}) - download it from https://imageoptim.com" unless image_optim
64
+ def self.images email, force=false
68
65
 
69
66
  images_path = email.image_dir
70
67
  cache_path = email.project_file(IMAGE_CACHE)
71
68
 
69
+ # Check to see if there is an image optim configuration file.
70
+ config_path = email.project_file(IMAGE_OPTIM_CONFIG_YML)
71
+ config_last_modified = Util.last_modified(config_path)
72
+
72
73
  # If the image cache exists, we need to check to see if any images have been
73
74
  # removed since the last build.
74
75
  if File.exists?(cache_path)
@@ -80,42 +81,54 @@ module Inkcite
80
81
 
81
82
  # Convert the images to fully-qualified paths and then remove
82
83
  # those files from the cache
83
- removed_images = removed_images.collect { |img| File.join(cache_path, img ) }
84
- FileUtils.rm (removed_images)
84
+ removed_images = removed_images.collect { |img| File.join(cache_path, img) }
85
+ FileUtils.rm(removed_images)
85
86
 
86
87
  end
87
88
 
88
89
  end
89
90
 
90
91
  # Check to see if there are new or updated images that need to be re-optimized.
91
- updated_images = Dir.entries(images_path).select do |img|
92
- unless img.start_with?('.')
93
- cimg = File.join(cache_path, img)
94
- !File.exists?(cimg) || (File.stat(File.join(images_path, img)).mtime > File.stat(cimg).mtime)
95
- end
92
+ # Compare existing images against both the most recently cached version and
93
+ # the timestamp of the config file.
94
+ updated_images = Dir.glob(File.join(images_path, '*.*')).select do |img|
95
+ cached_img = File.join(cache_path, File.basename(img))
96
+ cache_last_modified = Util.last_modified(cached_img)
97
+ force || config_last_modified > cache_last_modified || Util.last_modified(img) > cache_last_modified
96
98
  end
97
99
 
100
+ # Return unless there is something to compress
98
101
  return if updated_images.blank?
99
102
 
100
- # This is the temporary path into which new or updated images will
101
- # be copied and then optimized.
102
- temp_path = email.project_file(IMAGE_TEMP)
103
+ FileUtils.mkpath(cache_path)
104
+
105
+ # Check to see if there is an image_optim.yml file in this directory that
106
+ # overrides the default settings.
107
+ image_optim_opts = if config_last_modified > 0
108
+ {
109
+ :config_paths => [IMAGE_OPTIM_CONFIG_YML]
110
+ }
111
+ else
112
+ {
113
+ :allow_lossy => true,
114
+ :gifsicle => { :level => 3 },
115
+ :jpegoptim => { :max_quality => 50 },
116
+ :jpegrecompress => { :quality => 1 },
117
+ :pngout => false,
118
+ :svgo => false
119
+ }
120
+ end
103
121
 
104
- # Make sure there is no existing temporary directory to interfere
105
- # with the image processing.
106
- FileUtils.rm_rf(temp_path)
107
- FileUtils.mkpath(temp_path)
122
+ image_optim = ImageOptim.new(image_optim_opts)
108
123
 
109
124
  # Copy all of the images that need updating into the temporary directory.
110
125
  # Specifically joining the images_path to the image to avoid Email's
111
126
  # image_path which may change it's directory if optimization is enabled.
112
- updated_images.each { |img| FileUtils.cp(File.join(images_path, img), File.join(temp_path, img)) }
113
-
114
- # Optimize all of the images.
115
- system("#{image_optim_path} #{temp_path}") if image_optim
116
-
117
- FileUtils.cp_r(File.join(temp_path, "."), cache_path)
118
- FileUtils.rm_rf(temp_path)
127
+ updated_images.each do |img|
128
+ cached_img = File.join(cache_path, File.basename(img))
129
+ FileUtils.cp(img, cached_img)
130
+ image_optim.optimize_image!(cached_img)
131
+ end
119
132
 
120
133
  end
121
134
 
@@ -125,9 +138,11 @@ module Inkcite
125
138
 
126
139
  private
127
140
 
128
- # Temporary directory that new or updated images will be copied into
129
- # to be optimized and then cached in .images
130
- IMAGE_TEMP = ".images-temp"
141
+ # Name of the Image Optim configuration yml file that can be
142
+ # put in the project directory to explicitly control the image
143
+ # optimization process.
144
+ IMAGE_OPTIM_CONFIG_YML = 'image_optim.yml'
145
+
131
146
 
132
147
  NEW_LINE = "\n"
133
148
  MAXIMUM_LINE_LENGTH = 800
@@ -145,7 +160,7 @@ module Inkcite
145
160
  end
146
161
 
147
162
  def self.css_compressor ctx
148
- ctx.css_compressor ||= YUI::CssCompressor.new(:line_break => (ctx.email?? MAXIMUM_LINE_LENGTH : nil))
163
+ ctx.css_compressor ||= YUI::CssCompressor.new(:line_break => (ctx.email? ? MAXIMUM_LINE_LENGTH : nil))
149
164
  end
150
165
 
151
166
  end
@@ -12,7 +12,7 @@ module Inkcite
12
12
  end
13
13
 
14
14
  def bgcolor
15
- hex(@opt[:bgcolor] || @ctx[BUTTON_BACKGROUND_COLOR] || @ctx[Base::LINK_COLOR])
15
+ hex(@opt[:bgcolor] || @ctx[BUTTON_BGCOLOR] || @ctx[BUTTON_BACKGROUND_COLOR] || @ctx[Base::LINK_COLOR])
16
16
  end
17
17
 
18
18
  def border
@@ -89,6 +89,7 @@ module Inkcite
89
89
  BEVEL_COLOR = :'bevel-color'
90
90
 
91
91
  BUTTON_BACKGROUND_COLOR = :'button-background-color'
92
+ BUTTON_BGCOLOR = :'button-bgcolor'
92
93
  BUTTON_BEVEL = :'button-bevel'
93
94
  BUTTON_BEVEL_COLOR = :'button-bevel-color'
94
95
  BUTTON_BORDER = :'button-border'
@@ -129,26 +130,34 @@ module Inkcite
129
130
 
130
131
  # Responsive button is just a highly styled table/td combination with optional
131
132
  # curved corners and a lower bevel (border).
132
- html << "{table bgcolor=#{cfg.bgcolor}"
133
+ bgcolor = cfg.bgcolor
134
+ html << "{table bgcolor=#{bgcolor}"
133
135
  html << " padding=#{cfg.padding}" if cfg.padding > 0
134
- html << " border=#{cfg.border}" if cfg.border
136
+ html << %Q( border="#{cfg.border}") if cfg.border
135
137
  html << " border-radius=#{cfg.border_radius}" if cfg.border_radius > 0
138
+ html << %Q( border-bottom="#{cfg.border_bottom}") if cfg.bevel > 0
136
139
 
137
140
  # Need to separate borders that are collapsed by default - otherwise, the bevel
138
141
  # renders incorrectly.
139
- html << " border-bottom=\"#{cfg.border_bottom}\" border-collapse=separate" if cfg.bevel > 0
142
+ html << " border-collapse=separate" if cfg.border || cfg.bevel > 0
140
143
 
141
144
  html << " margin-top=#{cfg.margin_top}" if cfg.margin_top > 0
142
145
  html << " width=#{cfg.width}" if cfg.width > 0
143
146
  html << " float=#{cfg.float}" if cfg.float
144
- html << " mobile=\"fill\"}\n"
147
+ html << %Q( mobile="fill"}\n)
145
148
  html << "{td align=center"
146
149
  html << " height=#{cfg.height} valign=middle" if cfg.height > 0
147
150
  html << " font=\"#{cfg.font}\""
148
151
  html << " line-height=#{cfg.line_height}" unless cfg.line_height.blank?
149
- html << " font-size=\"#{cfg.font_size}\"" if cfg.font_size > 0
150
- html << " font-weight=\"#{cfg.font_weight}\"" unless cfg.font_weight.blank?
151
- html << " shadow=\"#{cfg.text_shadow}\" shadow-offset=-1}"
152
+ html << %Q( font-size="#{cfg.font_size}") if cfg.font_size > 0
153
+ html << %Q( font-weight="#{cfg.font_weight}") unless cfg.font_weight.blank?
154
+
155
+ # Text on the button gets a shadow automatically unless the shadow
156
+ # color matches the background color of the button.
157
+ shadow = cfg.text_shadow
158
+ html << %Q( shadow="#{shadow}" shadow-offset=-1) if shadow != bgcolor
159
+
160
+ html << '}'
152
161
 
153
162
  # Second, internal link for Outlook users that makes the inside of the button
154
163
  # clickable.
@@ -17,10 +17,16 @@ module Inkcite
17
17
  # when the {footnotes} tag is rendered.
18
18
  attr_reader :text
19
19
 
20
- def initialize id, symbol, text
20
+ # True if this footnote is active. By default all footnotes are
21
+ # activate but those read from footnotes.tsv are inactive until
22
+ # referenced in the source.
23
+ attr_accessor :active
24
+
25
+ def initialize id, symbol, text, active=true
21
26
  @id = id
22
27
  @symbol = symbol.to_s
23
28
  @text = text
29
+ @active = active
24
30
  end
25
31
 
26
32
  def number
@@ -33,6 +39,10 @@ module Inkcite
33
39
  @symbol == @symbol.to_i.to_s
34
40
  end
35
41
 
42
+ def symbol=symbol
43
+ @symbol = symbol.to_s
44
+ end
45
+
36
46
  def symbol?
37
47
  !numeric?
38
48
  end
@@ -59,19 +69,13 @@ module Inkcite
59
69
  # this isn't specified count the number of existing numeric footnotes
60
70
  # and increment it for this new footnote's symbol.
61
71
  symbol = opt[:symbol]
62
- if symbol.blank?
63
-
64
- # Grab the last numeric footnote that was specified and, assuming
65
- # there is one, increment the count. Otherwise, start the count
66
- # off at one.
67
- last_instance = ctx.footnotes.select(&:numeric?).last
68
- symbol = last_instance.nil? ? 1 : last_instance.symbol.to_i + 1
69
-
70
- end
71
72
 
72
73
  # Grab the text associated with this footnote.
73
74
  text = opt[:text]
74
- ctx.error("Footnote requires text attribute", { :id => id, :symbol => symbol }) if text.blank?
75
+ if text.blank?
76
+ ctx.error("Footnote requires text attribute", { :id => id, :symbol => symbol })
77
+ return
78
+ end
75
79
 
76
80
  # Create a new Footnote instance
77
81
  instance = Instance.new(id, symbol, text)
@@ -81,6 +85,24 @@ module Inkcite
81
85
 
82
86
  end
83
87
 
88
+ # Check to see if the footnote's symbol is blank (either because one
89
+ # wasn't defined in the source.html or because the one read from the
90
+ # footnotes.tsv had no symbol associated with it) and if so, generate
91
+ # one based on the number of previously declared numeric footnotes.
92
+ if instance.symbol.blank?
93
+
94
+ # Grab the last numeric footnote that was specified and, assuming
95
+ # there is one, increment the count. Otherwise, start the count
96
+ # off at one.
97
+ last_instance = ctx.footnotes.select(&:numeric?).last
98
+ instance.symbol = last_instance.nil? ? 1 : last_instance.symbol.to_i + 1
99
+
100
+ end
101
+
102
+ # Make sure the instance is marked as having been used so it will
103
+ # appear in the {footnotes} rendering.
104
+ instance.active = true
105
+
84
106
  # Allow footnotes to be defined without showing a symbol
85
107
  hidden = opt[:hidden].to_i == 1
86
108
  "#{instance.symbol}" unless hidden
@@ -94,6 +116,10 @@ module Inkcite
94
116
  # Nothing to do if footnotes are blank.
95
117
  return if ctx.footnotes.blank?
96
118
 
119
+ # Grab the active footnotes.
120
+ active_footnotes = ctx.footnotes.select(&:active)
121
+ return if active_footnotes.blank?
122
+
97
123
  # Check to see if a template has been provided. Otherwise use a default one based
98
124
  # on the format of the email.
99
125
  tmpl = opt[:tmpl] || opt[:template]
@@ -114,11 +140,11 @@ module Inkcite
114
140
 
115
141
  # First, collect all symbols in the natural order they are defined
116
142
  # in the email.
117
- footnotes = ctx.footnotes.select(&:symbol?)
143
+ footnotes = active_footnotes.select(&:symbol?)
118
144
 
119
145
  # Now add to the list all numeric footnotes ordered naturally
120
146
  # regardless of how they were ordered in the email.
121
- footnotes += ctx.footnotes.select(&:numeric?).sort { |f1, f2| f1.number <=> f2.number }
147
+ footnotes += active_footnotes.select(&:numeric?).sort { |f1, f2| f1.number <=> f2.number }
122
148
 
123
149
  html = ''
124
150
 
@@ -0,0 +1,79 @@
1
+ require 'litmus'
2
+
3
+ module Inkcite
4
+ module Renderer
5
+ class LitmusAnalytics < Base
6
+
7
+ def render tag, opt, ctx
8
+
9
+ # Litmus tracking is enabled only for production emails.
10
+ return nil unless ctx.production? && ctx.email?
11
+
12
+ # Deprecated code/id parameters. They shouldn't be passed anymore.
13
+ report_id = opt[:code] || opt[:id]
14
+ merge_tag = opt[MERGE_TAG] || ctx[MERGE_TAG]
15
+
16
+ # Initialize the Litmus API.
17
+ config = ctx.config[:litmus]
18
+ Litmus::Base.new(config[:subdomain], config[:username], config[:password], true)
19
+
20
+ # Will hold the Litmus Report object from which we'll retrieve the
21
+ # bug HTML to inject into the email.
22
+ report = nil
23
+
24
+ # If no code has been provided by the designer, check to see
25
+ # if one has been previously recorded for this version. If
26
+ # so, use it - otherwise, require one from litmus automatically.
27
+ if report_id.blank?
28
+
29
+ # Check to see if a campaign has been previously created for this
30
+ # version so the ID can be reused.
31
+ report_id = ctx.meta(:litmus_report_id)
32
+ if report_id.blank?
33
+
34
+ # Create a new report object using the title of the email specified
35
+ # in the helpers file.
36
+ report = Litmus::Report.create(ctx.title)
37
+
38
+ # Retrieve the unique ID assigned by Litmus and then stuff it
39
+ # into the meta data so we don't create a new one on future
40
+ # builds.
41
+ report_id = report['id']
42
+ ctx.set_meta :litmus_report_id, report_id
43
+
44
+ end
45
+
46
+ end
47
+
48
+ if report.nil?
49
+
50
+ report = Litmus::Report.show(report_id)
51
+ if report.nil?
52
+ ctx.error 'Invalid Litmus Analytics code or id', :code => report_id
53
+ return nil
54
+ end
55
+
56
+ end
57
+
58
+ # Grab the HTML from Litmus that needs to be injected into the source
59
+ # of the email.
60
+ bug_html = report[BUG_HTML]
61
+
62
+ # Replace the merge tag, if one was provided.
63
+ bug_html.gsub!('[UNIQUE]', merge_tag) unless merge_tag.nil?
64
+
65
+ # Inject HTML into the footer of the email where it won't be subject
66
+ # to inline'n or compression.
67
+ ctx.footer << bug_html
68
+
69
+ nil
70
+ end
71
+
72
+ private
73
+
74
+ BUG_HTML = 'bug_html'
75
+ MERGE_TAG = :'merge-tag'
76
+
77
+ end
78
+ end
79
+ end
@@ -8,8 +8,13 @@ module Inkcite
8
8
 
9
9
  span = Element.new('span')
10
10
 
11
+ padding = opt[:padding].to_i
12
+ span.style[:padding] = px(padding) if padding > 0
13
+
11
14
  mix_font span, opt, ctx
12
15
 
16
+ mix_background span, opt
17
+
13
18
  mix_responsive span, opt, ctx
14
19
 
15
20
  span.to_s
@@ -21,34 +21,43 @@ module Inkcite
21
21
  # css isn't supported.
22
22
  element[:bgcolor] = hex(bgcolor) unless bgcolor.blank?
23
23
 
24
- # Assisted background image handling for maximum compatibility.
25
24
  bgimage = opt[:background]
26
25
  bgposition = opt[BACKGROUND_POSITION]
27
26
  bgrepeat = opt[BACKGROUND_REPEAT]
27
+ bgsize = opt[BACKGROUND_SIZE]
28
+
29
+ # Sets the background image attributes in the element's style
30
+ # attribute. These values take precedence on the desktop
31
+ # version of the email.
32
+ desktop_background = mix_background_shorthand(
33
+ bgcolor,
34
+ bgimage,
35
+ bgposition,
36
+ bgrepeat,
37
+ bgsize,
38
+ ctx
39
+ )
28
40
 
29
- # No need to set any CSS if there is no background image present on this
30
- # element. Previously, it would also set the background-color attribute
31
- # for unnecessary duplication.
32
- background_css(element.style, bgcolor, bgimage, bgposition, bgrepeat, nil, false, ctx) unless bgimage.blank?
33
-
34
- m_bgcolor = detect(opt[MOBILE_BACKGROUND_COLOR], opt[MOBILE_BGCOLOR])
35
- m_bgimage = detect(opt[MOBILE_BACKGROUND_IMAGE], opt[MOBILE_BACKGROUND])
41
+ element.style[:background] = desktop_background unless bgimage.blank?
36
42
 
37
- mobile_background = background_css(
38
- {},
39
- m_bgcolor,
40
- m_bgimage,
43
+ # Set the mobile background image attributes. These values take
44
+ # precedence on the mobile version of the email. If unset the
45
+ # mobile version inherits from the desktop version.
46
+ mobile_background = mix_background_shorthand(
47
+ detect(opt[MOBILE_BACKGROUND_COLOR], opt[MOBILE_BGCOLOR], bgcolor),
48
+ detect(opt[MOBILE_BACKGROUND_IMAGE], opt[MOBILE_BACKGROUND], bgimage),
41
49
  detect(opt[MOBILE_BACKGROUND_POSITION], bgposition),
42
50
  detect(opt[MOBILE_BACKGROUND_REPEAT], bgrepeat),
43
- detect(opt[MOBILE_BACKGROUND_SIZE]),
44
- (m_bgcolor && bgcolor) || (m_bgimage && bgimage),
51
+ detect(opt[MOBILE_BACKGROUND_SIZE], bgsize),
45
52
  ctx
46
53
  )
47
54
 
48
- unless mobile_background.blank?
55
+ unless mobile_background.blank? || mobile_background == desktop_background
56
+
57
+ mobile_background << ' !important' unless desktop_background.blank?
49
58
 
50
59
  # Add the responsive rule that applies to this element.
51
- rule = Rule.new(element.tag, unique_klass(ctx), mobile_background)
60
+ rule = Rule.new(element.tag, unique_klass(ctx), { :background => mobile_background })
52
61
 
53
62
  # Add the rule to the view and the element
54
63
  ctx.media_query << rule
@@ -87,61 +96,43 @@ module Inkcite
87
96
 
88
97
  private
89
98
 
90
- def background_css into, bgcolor, img, position, repeat, size, important, ctx
99
+ def mix_background_shorthand bgcolor, img, position, repeat, size, ctx
91
100
 
92
- unless bgcolor.blank? && img.blank?
101
+ values = []
93
102
 
94
- bgcolor = hex(bgcolor) unless bgcolor.blank?
103
+ values << hex(bgcolor) unless none?(bgcolor)
104
+
105
+ unless img.blank?
95
106
 
96
107
  # If no image has been provided or if the image provided is equal
97
108
  # to "none" then we'll set the values independently. Otherwise
98
109
  # we'll use a composite background declaration.
99
110
  if none?(img)
111
+ values << 'none'
100
112
 
101
- unless bgcolor.blank?
102
- bgcolor << ' !important' if important
103
- into[BACKGROUND_COLOR] = bgcolor
104
- end
113
+ else
105
114
 
106
- # Check specifically for a value of "none" which allows the email
107
- # designer to the background that is otherwise present on the
108
- # desktop version of the email.
109
- if img == NONE
110
- img = 'none'
111
- img << ' !important' if important
112
- into[BACKGROUND_IMAGE] = img
113
- end
115
+ values << "url(#{ctx.image_url(img)})"
114
116
 
115
- else
117
+ position = '0% 0%' if position.blank? && !size.blank?
118
+ unless position.blank?
119
+ values << position
120
+ unless size.blank?
121
+ values << '/'
122
+ values << (size == 'fill' ? '100% auto' : size)
123
+ end
124
+ end
116
125
 
117
126
  # Default to no-repeat if a position has been supplied or replace
118
127
  # 'none' as a convenience (cause none is easier to type than no-repeat).
119
128
  repeat = 'no-repeat' if (repeat.blank? && !position.blank?) || repeat == NONE
129
+ values << repeat unless repeat.blank?
120
130
 
121
- sty = []
122
- sty << bgcolor unless bgcolor.blank?
123
-
124
- ctx.assert_image_exists(img)
125
-
126
- sty << "url(#{ctx.image_url(img)})"
127
- sty << position unless position.blank?
128
- sty << repeat unless repeat.blank?
129
- sty << '!important' if important
130
-
131
- into[:background] = sty.join(' ')
132
-
133
- end
134
-
135
- # Background size needs to be set independently. Perhaps it can be
136
- # mixed into background: but I couldn't make it work.
137
- unless size.blank?
138
- into[BACKGROUND_SIZE] = size
139
- into[BACKGROUND_SIZE] << ' !important' if important
140
131
  end
141
132
 
142
133
  end
143
134
 
144
- into
135
+ values.blank?? nil : values.join(' ')
145
136
  end
146
137
 
147
138
  end
@@ -84,9 +84,6 @@ module Inkcite
84
84
  CLOSE_TD = '/td'
85
85
  LEFT = 'left'
86
86
 
87
- # Property which controls the color of text
88
- TEXT_COLOR = :'#text'
89
-
90
87
  end
91
88
  end
92
89
  end
@@ -13,7 +13,7 @@ require_relative 'renderer/in_browser'
13
13
  require_relative 'renderer/increment'
14
14
  require_relative 'renderer/like'
15
15
  require_relative 'renderer/link'
16
- require_relative 'renderer/litmus'
16
+ require_relative 'renderer/litmus_analytics'
17
17
  require_relative 'renderer/lorem'
18
18
  require_relative 'renderer/mobile_image'
19
19
  require_relative 'renderer/mobile_style'
@@ -161,7 +161,7 @@ module Inkcite
161
161
  :'in-browser' => InBrowser.new,
162
162
  :include => Partial.new,
163
163
  :like => Like.new,
164
- :litmus => Litmus.new,
164
+ :litmus => LitmusAnalytics.new,
165
165
  :lorem => Lorem.new,
166
166
  :'mobile-img' => MobileImage.new,
167
167
  :'mobile-style' => MobileStyle.new,
@@ -75,6 +75,10 @@ module Inkcite
75
75
  # The preview version defines the configuration for the server to which
76
76
  # the files will be sftp'd.
77
77
  config = email.config[:sftp]
78
+ if config.nil? || config.blank?
79
+ puts "Unable to upload assets to CDN ('sftp:' section not found in config.yml)"
80
+ return
81
+ end
78
82
 
79
83
  # TODO: Verify SFTP configuration
80
84
  host = config[:host]
data/lib/inkcite/util.rb CHANGED
@@ -45,6 +45,10 @@ module Inkcite
45
45
 
46
46
  end
47
47
 
48
+ def self.last_modified file
49
+ file && File.exists?(file) ? File.mtime(file).to_i : 0
50
+ end
51
+
48
52
  def self.read *argv
49
53
  path = File.join(File.expand_path('../..', File.dirname(__FILE__)), argv)
50
54
  if File.exists?(path)
@@ -1,3 +1,3 @@
1
1
  module Inkcite
2
- VERSION = "1.2.0"
2
+ VERSION = "1.6.0"
3
3
  end