inkcite 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (75) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +20 -0
  3. data/README.md +110 -0
  4. data/Rakefile +8 -0
  5. data/assets/facebook-like.css +62 -0
  6. data/assets/facebook-like.js +59 -0
  7. data/assets/init/config.yml +97 -0
  8. data/assets/init/helpers.tsv +31 -0
  9. data/assets/init/source.html +60 -0
  10. data/assets/init/source.txt +6 -0
  11. data/bin/inkcite +6 -0
  12. data/inkcite.gemspec +42 -0
  13. data/lib/inkcite.rb +32 -0
  14. data/lib/inkcite/cli/base.rb +128 -0
  15. data/lib/inkcite/cli/build.rb +130 -0
  16. data/lib/inkcite/cli/init.rb +58 -0
  17. data/lib/inkcite/cli/preview.rb +30 -0
  18. data/lib/inkcite/cli/server.rb +123 -0
  19. data/lib/inkcite/cli/test.rb +61 -0
  20. data/lib/inkcite/email.rb +219 -0
  21. data/lib/inkcite/mailer.rb +140 -0
  22. data/lib/inkcite/minifier.rb +151 -0
  23. data/lib/inkcite/parser.rb +111 -0
  24. data/lib/inkcite/renderer.rb +177 -0
  25. data/lib/inkcite/renderer/base.rb +186 -0
  26. data/lib/inkcite/renderer/button.rb +168 -0
  27. data/lib/inkcite/renderer/div.rb +29 -0
  28. data/lib/inkcite/renderer/element.rb +82 -0
  29. data/lib/inkcite/renderer/footnote.rb +132 -0
  30. data/lib/inkcite/renderer/google_analytics.rb +35 -0
  31. data/lib/inkcite/renderer/image.rb +95 -0
  32. data/lib/inkcite/renderer/image_base.rb +82 -0
  33. data/lib/inkcite/renderer/in_browser.rb +38 -0
  34. data/lib/inkcite/renderer/like.rb +73 -0
  35. data/lib/inkcite/renderer/link.rb +243 -0
  36. data/lib/inkcite/renderer/litmus.rb +33 -0
  37. data/lib/inkcite/renderer/lorem.rb +39 -0
  38. data/lib/inkcite/renderer/mobile_image.rb +67 -0
  39. data/lib/inkcite/renderer/mobile_style.rb +40 -0
  40. data/lib/inkcite/renderer/mobile_toggle.rb +27 -0
  41. data/lib/inkcite/renderer/outlook_background.rb +48 -0
  42. data/lib/inkcite/renderer/partial.rb +31 -0
  43. data/lib/inkcite/renderer/preheader.rb +22 -0
  44. data/lib/inkcite/renderer/property.rb +39 -0
  45. data/lib/inkcite/renderer/responsive.rb +334 -0
  46. data/lib/inkcite/renderer/span.rb +21 -0
  47. data/lib/inkcite/renderer/table.rb +67 -0
  48. data/lib/inkcite/renderer/table_base.rb +149 -0
  49. data/lib/inkcite/renderer/td.rb +92 -0
  50. data/lib/inkcite/uploader.rb +173 -0
  51. data/lib/inkcite/util.rb +85 -0
  52. data/lib/inkcite/version.rb +3 -0
  53. data/lib/inkcite/view.rb +745 -0
  54. data/lib/inkcite/view/context.rb +38 -0
  55. data/lib/inkcite/view/media_query.rb +60 -0
  56. data/lib/inkcite/view/tag_stack.rb +38 -0
  57. data/test/email_spec.rb +16 -0
  58. data/test/parser_spec.rb +72 -0
  59. data/test/project/config.yml +98 -0
  60. data/test/project/helpers.tsv +56 -0
  61. data/test/project/images/inkcite.jpg +0 -0
  62. data/test/project/source.html +58 -0
  63. data/test/project/source.txt +6 -0
  64. data/test/renderer/button_spec.rb +45 -0
  65. data/test/renderer/div_spec.rb +101 -0
  66. data/test/renderer/element_spec.rb +31 -0
  67. data/test/renderer/footnote_spec.rb +57 -0
  68. data/test/renderer/image_spec.rb +82 -0
  69. data/test/renderer/link_spec.rb +84 -0
  70. data/test/renderer/mobile_image_spec.rb +27 -0
  71. data/test/renderer/mobile_style_spec.rb +37 -0
  72. data/test/renderer/td_spec.rb +126 -0
  73. data/test/renderer_spec.rb +28 -0
  74. data/test/view_spec.rb +15 -0
  75. metadata +333 -0
@@ -0,0 +1,61 @@
1
+ require 'litmus'
2
+ require 'inkcite/mailer'
3
+
4
+ module Inkcite
5
+ module Cli
6
+ class Test
7
+
8
+ def self.invoke email, opt
9
+
10
+ # Push the browser preview up to the server to ensure that the
11
+ # latest images are available.
12
+ email.upload
13
+
14
+ config = email.config[:litmus]
15
+
16
+ # Initialize the Litmus base.
17
+ Litmus::Base.new(config[:subdomain], config[:username], config[:password], true)
18
+
19
+ # Send each version to Litmus separately
20
+ email.versions.each do |version|
21
+
22
+ view = email.view(:preview, :email, version)
23
+
24
+ # This will hold the Litmus Test Version which provides the GUID (e.g. email)
25
+ # to which we will send.
26
+ test_version = nil
27
+
28
+ # Check to see if this email already has a test ID.
29
+ test_id = view.meta(:litmus_test_id)
30
+ if test_id.blank? || opt[:new]
31
+
32
+ email_test = Litmus::EmailTest.create
33
+
34
+ # Store the litmus test ID in the email's meta data.
35
+ view.set_meta :litmus_test_id, email_test['id']
36
+
37
+ # Extract the email address we need to send the test to.
38
+ test_version = email_test["test_set_versions"].first
39
+
40
+ else
41
+
42
+ # Create a new version of the test using the same ID as before.
43
+ test_version = Litmus::TestVersion.create(test_id)
44
+
45
+ end
46
+
47
+ # Extract the email address to send the test to.
48
+ send_to = test_version["url_or_guid"]
49
+
50
+ puts "Sending '#{view.subject}' to #{send_to} ..."
51
+
52
+ Inkcite::Mailer.litmus(email, version, send_to)
53
+
54
+ end
55
+
56
+ true
57
+ end
58
+
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,219 @@
1
+ module Inkcite
2
+ class Email
3
+
4
+ CACHE_BUST = :'cache-bust'
5
+ IMAGE_HOST = :'image-host'
6
+ IMAGE_PLACEHOLDERS = :'image-placeholders'
7
+ OPTIMIZE_IMAGES = :'optimize-images'
8
+ TRACK_LINKS = :'track-links'
9
+ VIEW_IN_BROWSER_URL = :'view-in-browser-url'
10
+
11
+ # Sub-directory where images are located.
12
+ IMAGES = 'images'
13
+
14
+ # Allowed environments.
15
+ ENVIRONMENTS = [ :development, :preview, :production ].freeze
16
+
17
+ # The path to the directory from which the email is being generated.
18
+ # e.g. /projects/emails/holiday-mailing
19
+ attr_reader :path
20
+
21
+ def initialize path
22
+ @path = path
23
+ end
24
+
25
+ def config
26
+ Util.read_yml(File.join(path, 'config.yml'), true)
27
+ end
28
+
29
+ def formats env
30
+
31
+ f = [ :email, :browser ]
32
+
33
+ # Need to make sure a source.txt exists before we can include
34
+ # it in the list of known formats.
35
+ f << :text if File.exists?(project_file('source.txt'))
36
+
37
+ f
38
+ end
39
+
40
+ def image_dir
41
+ File.join(path, IMAGES)
42
+ end
43
+
44
+ def image_path file
45
+ File.join(image_dir, file)
46
+ end
47
+
48
+ def meta key
49
+ meta_data[key.to_sym]
50
+ end
51
+
52
+ def optimize_images
53
+ Minifier.images(self)
54
+ end
55
+
56
+ def optimize_images?
57
+ config[Inkcite::Email::OPTIMIZE_IMAGES] == true
58
+ end
59
+
60
+ # Returns the directory that optimized, compressed images
61
+ # have been saved to.
62
+ def optimized_image_dir
63
+ File.join(path, optimize_images?? Minifier::IMAGE_CACHE : IMAGES)
64
+ end
65
+
66
+ def properties
67
+
68
+ opts = {
69
+ :n => NEW_LINE
70
+ }
71
+
72
+ # Load the project's properties, which may include references to additional
73
+ # properties in other directories.
74
+ read_properties opts, 'helpers.tsv'
75
+
76
+ # As a convenience pre-populate the month name of the email.
77
+ mm = opts[:mm].to_i
78
+ opts[:month] = Date::MONTHNAMES[mm] if mm > 0
79
+
80
+ opts
81
+ end
82
+
83
+ def project_file file
84
+ File.join(path, file)
85
+ end
86
+
87
+ def set_meta key, value
88
+ md = meta_data
89
+ md[key.to_sym] = value
90
+ File.open(File.join(path, meta_file_name), 'w+') { |f| f.write(md.to_yaml) }
91
+ value
92
+ end
93
+
94
+ def upload
95
+ require_relative 'uploader'
96
+ Uploader.upload(self)
97
+ end
98
+
99
+ def upload!
100
+ require_relative 'uploader'
101
+ Uploader.upload!(self)
102
+ end
103
+
104
+ def versions
105
+ [* self.config[:versions] || :default ].collect(&:to_sym)
106
+ end
107
+
108
+ def view environment, format, version=nil
109
+
110
+ environment = environment.to_sym
111
+ format = format.to_sym
112
+ version = (version || versions.first).to_sym
113
+
114
+ raise "Unknown environment \"#{environment}\" - must be one of #{ENVIRONMENTS.join(',')}" unless ENVIRONMENTS.include?(environment)
115
+ raise "Unknown format \"#{format}\" - must be one of #{FORMATS.join(',')}" unless FORMATS.include?(format)
116
+ raise "Unknown version: \"#{version}\" - must be one of #{versions.join(',')}" unless versions.include?(version)
117
+
118
+ opt = properties
119
+ opt.merge!(self.config)
120
+
121
+ # Instantiate a new view of this email with the desired view and
122
+ # format.
123
+ View.new(self, environment, format, version, opt)
124
+
125
+ end
126
+
127
+ # Returns an array of all possible Views (every combination of version
128
+ # and format )of this email for the designated environment.
129
+ def views environment, &block
130
+
131
+ vs = []
132
+
133
+ formats(environment).each do |format|
134
+ versions.each do |version|
135
+ ev = view(environment, format, version)
136
+ yield(ev) if block_given?
137
+ vs << ev
138
+ end
139
+ end
140
+
141
+ vs
142
+ end
143
+
144
+ private
145
+
146
+ # Allowed formats.
147
+ FORMATS = [ :browser, :email, :text ].freeze
148
+
149
+ # Name of the property controlling the meta data file name and
150
+ # the default file name.
151
+ META_FILE_NAME = :'meta-file'
152
+ META_FILE = '.inkcite'
153
+
154
+ COMMENT = '//'
155
+ NEW_LINE = "\n"
156
+ TAB = "\t"
157
+ CARRIAGE_RETURN = "\r"
158
+
159
+ # Used for
160
+ MULTILINE_START = "<<-START"
161
+ MULTILINE_END = "END->>"
162
+ TAB_TO_SPACE = ' '
163
+
164
+ def meta_data
165
+ Util.read_yml(File.join(path, meta_file_name), false)
166
+ end
167
+
168
+ def meta_file_name
169
+ config[META_FILE_NAME] || META_FILE
170
+ end
171
+
172
+ def read_properties into, file
173
+
174
+ fp = File.join(path, file)
175
+ abort("Can't find #{file} in #{path} - are you sure this is an Inkcite project?") unless File.exists?(fp)
176
+
177
+ # Consolidate line-breaks for simplicity
178
+ raw = File.read(fp)
179
+ raw.gsub!(/[\r\f\n]{1,}/, NEW_LINE)
180
+
181
+ # Initial position of the
182
+ multiline_starts_at = 0
183
+
184
+ # Determine if there are any multiline declarations - those that are wrapped with
185
+ # <<-START and END->> and reduce them to single line declarations.
186
+ while (multiline_starts_at = raw.index(MULTILINE_START, multiline_starts_at))
187
+
188
+ break unless (multiline_ends_at = raw.index(MULTILINE_END, multiline_starts_at))
189
+
190
+ declaration = raw[(multiline_starts_at+MULTILINE_START.length)..multiline_ends_at - 1]
191
+ declaration.strip!
192
+ declaration.gsub!(/\t/, TAB_TO_SPACE)
193
+ declaration.gsub!(/\n/, "\r")
194
+
195
+ raw[multiline_starts_at..multiline_ends_at+MULTILINE_END.length - 1] = declaration
196
+
197
+ end
198
+
199
+ raw.split(NEW_LINE).each do |line|
200
+ next if line.starts_with?(COMMENT)
201
+
202
+ line.gsub!(/\r/, NEW_LINE)
203
+ line.strip!
204
+
205
+ key, open, close = line.split(TAB)
206
+ next if key.blank?
207
+
208
+ into[key.to_sym] = open.to_s.freeze
209
+
210
+ # Prepend the key with a "/" and populate the closing tag.
211
+ into["/#{key}".to_sym] = close.to_s.freeze
212
+
213
+ end
214
+
215
+ true
216
+ end
217
+
218
+ end
219
+ end
@@ -0,0 +1,140 @@
1
+ require 'mail'
2
+
3
+ module Inkcite
4
+ class Mailer
5
+
6
+ def self.client email
7
+
8
+ # Determine which preview this is
9
+ count = increment(email, :preview)
10
+
11
+ # Get the declared set of recipients.
12
+ recipients = email.config[:recipients]
13
+
14
+ # Get the list of client address(es) - check both client and clients
15
+ # as a convenience.
16
+ to = recipients[:clients] || recipients[:client]
17
+
18
+ # If this is the first preview, check to see if there is an
19
+ # additional set of recipients for this initial mailing.
20
+ if count == 1
21
+ also_to = recipients[FIRST_PREVIEW]
22
+ #to = [* to] + [* also_to] unless also_to.blank?
23
+ to = [* also_to] unless also_to.blank?
24
+ end
25
+
26
+ # Always cc internal recipients so everyone stays informed of feedback.
27
+ cc = recipients[:internal]
28
+
29
+ self.send(email, {
30
+ :to => to,
31
+ :cc => cc,
32
+ :bcc => true,
33
+ :tag => "Preview ##{count}"
34
+ })
35
+
36
+ end
37
+
38
+ def self.developer email
39
+
40
+ count = increment(email, :developer)
41
+
42
+ self.send(email, {
43
+ :tag => "Developer Test ##{count}"
44
+ })
45
+
46
+ end
47
+
48
+ def self.litmus email, version, litmus_email
49
+ self.send_version(email, version, { :to => litmus_email })
50
+ end
51
+
52
+ def self.internal email
53
+
54
+ recipients = email.config[:recipients]
55
+
56
+ # Determine which preview this is
57
+ count = increment(email, :internal)
58
+
59
+ self.send(email, {
60
+ :to => recipients[:internal],
61
+ :bcc => true,
62
+ :tag => "Internal Proof ##{count}"
63
+ })
64
+
65
+ end
66
+
67
+ private
68
+
69
+ # Name of the distribution list used on the first preview. For one
70
+ # client, they wanted the first preview sent to additional people
71
+ # but subsequent previews went to a shorter list.
72
+ FIRST_PREVIEW = :'first-preview'
73
+
74
+ def self.increment email, sym
75
+ count = email.meta(sym).to_i + 1
76
+ email.set_meta sym, count
77
+ end
78
+
79
+ # Sends each version of the provided email with the indicated options.
80
+ def self.send email, opt
81
+
82
+ email.versions.each do |version|
83
+ self.send_version(email, version, opt)
84
+ end
85
+
86
+ end
87
+
88
+ def self.send_version email, version, opt
89
+
90
+ config = email.config[:smtp]
91
+
92
+ Mail.defaults do
93
+ delivery_method :smtp, {
94
+ :address => config[:host],
95
+ :port => config[:port],
96
+ :user_name => config[:username],
97
+ :password => config[:password],
98
+ :authentication => :plain,
99
+ :enable_starttls_auto => true
100
+ }
101
+ end
102
+
103
+ # The address of the developer
104
+ _from = config[:from]
105
+
106
+ # Subject line tag such as "Preview #3"
107
+ _tag = opt[:tag]
108
+
109
+ # True if the developer should be bcc'd.
110
+ _bcc = opt[:bcc] == true
111
+
112
+ # The version of the email we will be sending.
113
+ _view = email.view(:preview, :email, version)
114
+
115
+ _subject = _view.subject
116
+ _subject = "#{_subject} (#{_tag})" unless _tag.blank?
117
+
118
+ mail = Mail.new do
119
+
120
+ to opt[:to] || _from
121
+ cc opt[:cc]
122
+ from _from
123
+ subject _subject
124
+
125
+ bcc(_from) if _bcc
126
+
127
+ html_part do
128
+ content_type 'text/html; charset=UTF-8'
129
+ body _view.render!
130
+ end
131
+
132
+ end
133
+
134
+ mail.deliver!
135
+
136
+ end
137
+
138
+ end
139
+ end
140
+
@@ -0,0 +1,151 @@
1
+ require 'yui/compressor'
2
+
3
+ module Inkcite
4
+ class Minifier
5
+
6
+ # Directory of optimized images
7
+ IMAGE_CACHE = ".images"
8
+
9
+ def self.css code, ctx
10
+ minify?(ctx) ? css_compressor(ctx).compress(code) : code
11
+ end
12
+
13
+ def self.html lines, ctx
14
+
15
+ if minify?(ctx)
16
+
17
+ # Will hold the assembled, minified HTML as it is prepared.
18
+ html = ''
19
+
20
+ # Will hold the line being assembled until it reaches the maximum
21
+ # allowed line length.
22
+ packed_line = ''
23
+
24
+ lines.each do |line|
25
+ next if line.blank?
26
+
27
+ line.strip!
28
+
29
+ ## Compress all in-line styles.
30
+ #Parser.each line, INLINE_STYLE_REGEX do |style|
31
+ # style.gsub!(/: +/, ':')
32
+ # style.gsub!(/; +/, ';')
33
+ # style.gsub!(/;+/, ';')
34
+ # style.gsub!(/;+$/, '')
35
+ # "style=\"#{style}\""
36
+ #end
37
+
38
+ # If the length of the packed line with the addition of this line of content would
39
+ # exceed the maximum allowed line length, then push the collected lines onto the
40
+ # html and start a new line.
41
+ if !packed_line.blank? && packed_line.length + line.length > MAXIMUM_LINE_LENGTH
42
+ html << packed_line
43
+ html << NEW_LINE
44
+ packed_line = ''
45
+ end
46
+
47
+ packed_line << line
48
+
49
+ end
50
+
51
+ # Make sure to get any last lines assembled on the packed line.
52
+ html << packed_line unless packed_line.blank?
53
+
54
+ html
55
+ else
56
+ lines.join(NEW_LINE)
57
+
58
+ end
59
+
60
+ end
61
+
62
+ def self.images email
63
+
64
+ image_optim_path = '/Applications/ImageOptim.app/Contents/MacOS/ImageOptim'
65
+ image_optim = File.exists?(image_optim_path)
66
+ abort "Can't find ImageOptim (#{image_optim_path}) - download it from https://imageoptim.com" unless image_optim
67
+
68
+ images_path = email.image_dir
69
+ cache_path = email.project_file(IMAGE_CACHE)
70
+
71
+ # If the image cache exists, we need to check to see if any images have been
72
+ # removed since the last build.
73
+ if File.exists?(cache_path)
74
+
75
+ # Get a list of the files in the cache that do not also exist in the
76
+ # project's images/ directory.
77
+ removed_images = Dir.entries(cache_path) - Dir.entries(images_path)
78
+ unless removed_images.blank?
79
+
80
+ # Convert the images to fully-qualified paths and then remove
81
+ # those files from the cache
82
+ removed_images = removed_images.collect { |img| File.join(cache_path, img ) }
83
+ FileUtils.rm (removed_images)
84
+
85
+ end
86
+
87
+ end
88
+
89
+ # Check to see if there are new or updated images that need to be re-optimized.
90
+ updated_images = Dir.entries(images_path).select do |img|
91
+ unless img.start_with?('.')
92
+ cimg = File.join(cache_path, img)
93
+ !File.exists?(cimg) || (File.stat(File.join(images_path, img)).mtime > File.stat(cimg).mtime)
94
+ end
95
+ end
96
+
97
+ return if updated_images.blank?
98
+
99
+ # This is the temporary path into which new or updated images will
100
+ # be copied and then optimized.
101
+ temp_path = email.project_file(IMAGE_TEMP)
102
+
103
+ # Make sure there is no existing temporary directory to interfere
104
+ # with the image processing.
105
+ FileUtils.rm_rf(temp_path)
106
+ FileUtils.mkpath(temp_path)
107
+
108
+ # Copy all of the images that need updating into the temporary directory.
109
+ # Specifically joining the images_path to the image to avoid Email's
110
+ # image_path which may change it's directory if optimization is enabled.
111
+ updated_images.each { |img| FileUtils.cp(File.join(images_path, img), File.join(temp_path, img)) }
112
+
113
+ # Optimize all of the images.
114
+ system("#{image_optim_path} #{temp_path}") if image_optim
115
+
116
+ FileUtils.cp_r(File.join(temp_path, "."), cache_path)
117
+ FileUtils.rm_rf(temp_path)
118
+
119
+ end
120
+
121
+ def self.js code, ctx
122
+ minify?(ctx) ? js_compressor(ctx).compress(code) : code
123
+ end
124
+
125
+ private
126
+
127
+ # Temporary directory that new or updated images will be copied into
128
+ # to be optimized and then cached in .images
129
+ IMAGE_TEMP = ".images-temp"
130
+
131
+ NEW_LINE = "\n"
132
+ MAXIMUM_LINE_LENGTH = 800
133
+
134
+ # Used to match inline styles that will be compressed when minifying
135
+ # the entire email.
136
+ INLINE_STYLE_REGEX = /style=\"([^\"]+)\"/
137
+
138
+ def self.minify? ctx
139
+ ctx.is_enabled?(:minify)
140
+ end
141
+
142
+ def self.js_compressor ctx
143
+ ctx.js_compressor ||= YUI::JavaScriptCompressor.new(:munge => true)
144
+ end
145
+
146
+ def self.css_compressor ctx
147
+ ctx.css_compressor ||= YUI::CssCompressor.new(:line_break => (ctx.email?? MAXIMUM_LINE_LENGTH : nil))
148
+ end
149
+
150
+ end
151
+ end