inkcite 1.0.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.
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