inkcite 1.2.0 → 1.6.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.
@@ -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