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,21 @@
1
+ module Inkcite
2
+ module Renderer
3
+ class Span < Responsive
4
+
5
+ def render tag, opt, ctx
6
+
7
+ return '</span>' if tag == '/span'
8
+
9
+ span = Element.new('span')
10
+
11
+ mix_font span, opt, ctx
12
+
13
+ mix_responsive span, opt, ctx
14
+
15
+ span.to_s
16
+ end
17
+
18
+ end
19
+ end
20
+ end
21
+
@@ -0,0 +1,67 @@
1
+ module Inkcite
2
+ module Renderer
3
+ class Table < TableBase
4
+
5
+ def render tag, opt, ctx
6
+
7
+ tag_stack = ctx.tag_stack(:table)
8
+
9
+ if tag == CLOSE_TABLE
10
+
11
+ # Remove this table from the stack of previously open tags.
12
+ tag_stack.pop
13
+
14
+ return '</tr></table>'
15
+
16
+ end
17
+
18
+ # Push this table onto the stack which will make it's declaration
19
+ # available to its child TDs.
20
+ tag_stack << opt
21
+
22
+ table = Element.new(tag, { :border => 0, :cellspacing => 0 })
23
+
24
+ # Inherit base cell attributes - border, background color and image, etc.
25
+ mix_all table, opt, ctx
26
+
27
+ # Text shadowing
28
+ mix_text_shadow table, opt, ctx
29
+
30
+ # Conveniently accept padding (easier to type and consistent with CSS)or
31
+ # cellpadding which must always be declared.
32
+ table[:cellpadding] = (opt[:padding] || opt[:cellpadding]).to_i
33
+
34
+ # Conveniently accept both float and align to mean the same thing.
35
+ align = opt[:align] || opt[:float]
36
+ table[:align] = align unless align.blank?
37
+
38
+ border_radius = opt[BORDER_RADIUS].to_i
39
+ table.style[BORDER_RADIUS] = px(border_radius) if border_radius > 0
40
+
41
+ border_collapse = opt[BORDER_COLLAPSE]
42
+ table.style[BORDER_COLLAPSE] = border_collapse unless border_collapse.blank?
43
+
44
+ margin_top = opt[MARGIN_TOP].to_i
45
+ table.style[MARGIN_TOP] = px(margin_top) if margin_top > 0
46
+
47
+ mobile = opt[:mobile]
48
+
49
+ # When a Table is configured to have it's cells DROP then it
50
+ # actually needs to FILL on mobile and it's child Tds will
51
+ # be DROP'd. Override the local mobile klass so the child Tds
52
+ # see the parent as DROP.
53
+ mobile = FILL if mobile == DROP || mobile == SWITCH
54
+
55
+ mix_responsive table, opt, ctx, mobile
56
+
57
+ table.to_s + '<tr>'
58
+ end
59
+
60
+ private
61
+
62
+ CLOSE_TABLE = '/table'
63
+
64
+ end
65
+ end
66
+ end
67
+
@@ -0,0 +1,149 @@
1
+ module Inkcite
2
+ module Renderer
3
+ class TableBase < Responsive
4
+
5
+ protected
6
+
7
+ def mix_all element, opt, ctx
8
+
9
+ mix_background element, opt, ctx
10
+ mix_border element, opt, ctx
11
+ mix_dimensions element, opt, ctx
12
+
13
+ end
14
+
15
+ def mix_background element, opt, ctx
16
+
17
+ bgcolor = opt[:bgcolor]
18
+ bgcolor = nil if bgcolor == NONE
19
+
20
+ # Set the bgcolor attribute of the element as a fallback if
21
+ # css isn't supported.
22
+ element[:bgcolor] = hex(bgcolor) unless bgcolor.blank?
23
+
24
+ # Assisted background image handling for maximum compatibility.
25
+ bgimage = opt[:background]
26
+ bgposition = opt[BACKGROUND_POSITION]
27
+ bgrepeat = opt[BACKGROUND_REPEAT]
28
+
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])
36
+
37
+ mobile_background = background_css(
38
+ {},
39
+ m_bgcolor,
40
+ m_bgimage,
41
+ detect(opt[MOBILE_BACKGROUND_POSITION], bgposition),
42
+ detect(opt[MOBILE_BACKGROUND_REPEAT], bgrepeat),
43
+ detect(opt[MOBILE_BACKGROUND_SIZE]),
44
+ (m_bgcolor && bgcolor) || (m_bgimage && bgimage),
45
+ ctx
46
+ )
47
+
48
+ unless mobile_background.blank?
49
+
50
+ # Add the responsive rule that applies to this element.
51
+ rule = Rule.new(element.tag, unique_klass(ctx), mobile_background)
52
+
53
+ # Add the rule to the view and the element
54
+ ctx.media_query << rule
55
+ element.add_rule rule
56
+
57
+ end
58
+
59
+ end
60
+
61
+ def mix_border element, opt, ctx
62
+
63
+ border = opt[:border]
64
+ element.style[:border] = border unless border.blank?
65
+
66
+ # Iterate through each of the possible borders and apply them individually
67
+ # to the style if they are defined.
68
+ DIRECTIONS.each do |dir|
69
+ key = :"border-#{dir}"
70
+ border = opt[key]
71
+ element.style[key] = border unless border.blank? || border == NONE
72
+ end
73
+
74
+ end
75
+
76
+ def mix_dimensions element, opt, ctx
77
+
78
+ # Not taking .to_i because we want to accept both integer values
79
+ # or percentages - e.g. 50%
80
+ width = opt[:width]
81
+ element[:width] = width unless width.blank?
82
+
83
+ height = opt[:height].to_i
84
+ element[:height] = height if height > 0
85
+
86
+ end
87
+
88
+ private
89
+
90
+ def background_css into, bgcolor, img, position, repeat, size, important, ctx
91
+
92
+ unless bgcolor.blank? && img.blank?
93
+
94
+ bgcolor = hex(bgcolor) unless bgcolor.blank?
95
+
96
+ # If no image has been provided or if the image provided is equal
97
+ # to "none" then we'll set the values independently. Otherwise
98
+ # we'll use a composite background declaration.
99
+ if none?(img)
100
+
101
+ unless bgcolor.blank?
102
+ bgcolor << ' !important' if important
103
+ into[BACKGROUND_COLOR] = bgcolor
104
+ end
105
+
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
114
+
115
+ else
116
+
117
+ # Default to no-repeat if a position has been supplied or replace
118
+ # 'none' as a convenience (cause none is easier to type than no-repeat).
119
+ repeat = 'no-repeat' if (repeat.blank? && !position.blank?) || repeat == NONE
120
+
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
+ end
141
+
142
+ end
143
+
144
+ into
145
+ end
146
+
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,92 @@
1
+ module Inkcite
2
+ module Renderer
3
+ class Td < TableBase
4
+
5
+ def render tag, opt, ctx
6
+
7
+ tag_stack = ctx.tag_stack(:td)
8
+
9
+ if tag == CLOSE_TD
10
+ tag_stack.pop
11
+ return '</td>'
12
+ end
13
+
14
+ # Push this tag onto the stack so that child elements (e.g. links)
15
+ # can have access to its attributes.
16
+ tag_stack << opt
17
+
18
+ # Grab the attributes of the parent table so that the TD can inherit
19
+ # specific values like padding, valign, responsiveness, etc.
20
+ parent = ctx.parent_opts(:table)
21
+
22
+ td = Element.new('td')
23
+
24
+ # Inherit base cell attributes - border, background color and image, etc.
25
+ mix_all td, opt, ctx
26
+
27
+ # Force the td to collapse to a single pixel to support images that
28
+ # are less than 15 pixels.
29
+ opt.merge!({
30
+ :font => NONE,
31
+ :color => NONE,
32
+ FONT_SIZE => 1,
33
+ LINE_HEIGHT => 1
34
+ }) unless opt[:flush].blank?
35
+
36
+ # It is a best-practice to declare the same padding on all cells in a
37
+ # table. Check to see if padding was declared on the parent.
38
+ padding = parent[:padding].to_i
39
+ td.style[:padding] = px(padding) if padding > 0
40
+
41
+ # Custom handling for text align on TDs rather than Base's mix_text_align
42
+ # because if possible, using align= rather than a style keeps emails
43
+ # smaller. But for left-aligned text, you gotta use a style because
44
+ # you know, Outlook.
45
+ align = opt[:align]
46
+ unless align.blank?
47
+ td[:align] = align
48
+
49
+ # Must use style to reinforce left-align text in certain email clients.
50
+ # All other alignments are accepted naturally.
51
+ td.style[TEXT_ALIGN] = align if align == LEFT
52
+
53
+ end
54
+
55
+ valign = detect(opt[:valign], parent[:valign])
56
+ td[:valign] = valign unless valign.blank?
57
+
58
+ rowspan = opt[:rowspan].to_i
59
+ td[:rowspan] = rowspan if rowspan > 0
60
+
61
+ mix_font td, opt, ctx, parent
62
+
63
+ mobile = opt[:mobile]
64
+ if mobile.blank?
65
+
66
+ # If the cell doesn't define it's own responsive behavior, check to
67
+ # see if it inherits from its parent table. DROP and SWITCH declared
68
+ # at the table-level descend to their tds.
69
+ pm = parent[:mobile]
70
+ mobile = pm if pm == DROP || pm == SWITCH
71
+
72
+ end
73
+
74
+ mix_responsive td, opt, ctx, mobile
75
+
76
+ #outlook-bg <!-&#45;&#91;if gte mso 9]>[n]<v:rect style="width:%width%px;height:%height%px;" strokecolor="none"><v:fill type="tile" src="%src%" /></v:fill></v:rect><v:shape id="theText[rnd]" style="position:absolute;width:%width%px;height:%height%px;margin:0;padding:0;%style%">[n]<!&#91;endif]&#45;->
77
+ #/outlook-bg <!-&#45;&#91;if gte mso 9]></v:shape><!&#91;endif]&#45;->
78
+
79
+ td.to_s
80
+ end
81
+
82
+ private
83
+
84
+ CLOSE_TD = '/td'
85
+ LEFT = 'left'
86
+
87
+ # Property which controls the color of text
88
+ TEXT_COLOR = :'#text'
89
+
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,173 @@
1
+ require 'net/sftp'
2
+ require 'stringio'
3
+
4
+ module Inkcite
5
+ class Uploader
6
+
7
+ def self.upload email
8
+
9
+ times = []
10
+
11
+ [ 'source.html', 'source.txt', 'helpers.tsv' ].each do |file|
12
+ file = email.project_file(file)
13
+ times << File.mtime(file).to_i if File.exists?(file)
14
+ end
15
+
16
+ local_images = email.image_dir
17
+ if File.exists?(local_images)
18
+ Dir.foreach(local_images) do |file|
19
+ times << File.mtime(File.join(local_images, file)).to_i unless file.starts_with?('.')
20
+ end
21
+ end
22
+
23
+ # Get the most recently updated file.
24
+ last_update = times.max
25
+
26
+ # Determine when the last upload was completed.
27
+ last_upload = email.meta(:last_upload).to_i
28
+
29
+ self.do_upload(email, false) if last_update > last_upload
30
+
31
+ end
32
+
33
+ def self.upload! email
34
+ self.do_upload(email, true)
35
+ end
36
+
37
+ private
38
+
39
+ IMAGE_PATH = :'image-path'
40
+
41
+ def self.copy! sftp, local, remote, force=true
42
+
43
+ # Nothing to copy unless the local directory exists (e.g. some emails don't
44
+ # have an images directory.)
45
+ return unless File.exists?(local)
46
+
47
+ Dir.foreach(local) do |file|
48
+ next if file.starts_with?('.')
49
+
50
+ local_file = File.join(local, file)
51
+ unless File.directory?(local_file)
52
+
53
+ remote_file = File.join(remote, file)
54
+
55
+ unless force
56
+ next unless begin
57
+ File.stat(local_file).mtime > Time.at(sftp.stat!(remote_file).mtime)
58
+ rescue Net::SFTP::StatusException
59
+ true # File doesn't exist, so assume it's changed.
60
+ end
61
+ end
62
+
63
+ puts "Uploading #{local_file} -> #{remote_file} ..."
64
+ sftp.upload!(local_file, remote_file)
65
+
66
+ end
67
+
68
+ end
69
+ end
70
+
71
+ # Internal method responsive for doing the actual upload and
72
+ # forcing (if necessary) the update of the graphics.
73
+ def self.do_upload email, force
74
+
75
+ # The preview version defines the configuration for the server to which
76
+ # the files will be sftp'd.
77
+ config = email.config[:sftp]
78
+
79
+ # TODO: Verify SFTP configuration
80
+ host = config[:host]
81
+ path = config[:path]
82
+ username = config[:username]
83
+ password = config[:password]
84
+
85
+ # Pre-optimize images before we upload them to the CDN.
86
+ email.optimize_images
87
+
88
+ # This is the directory from which images will be uploaded.
89
+ # The email provides us with the correct directory based on
90
+ # whether or not image optimization is enabled.
91
+ local_images = email.optimized_image_dir
92
+
93
+ # This is the last location of image upload. If we're working
94
+ # on multiple versions but the images all point to the same
95
+ # location, it isn't necessary to re-upload images each time.
96
+ last_remote_root = nil
97
+
98
+ puts "Uploading to #{host} ..."
99
+
100
+ # Get a local handle on the litmus configuration.
101
+ Net::SFTP.start(host, username, :password => password) do |sftp|
102
+
103
+ # Upload each version of the email.
104
+ email.versions.each do |version|
105
+
106
+ view = email.view(:preview, :browser, version)
107
+
108
+ # Need to pass the upload path through the renderer to ensure
109
+ # that embedded tags will be converted into data.
110
+ remote_root = Inkcite::Renderer.render(path, view)
111
+
112
+ # Recursively ensure that the full directory structure necessary for
113
+ # the content and images is present.
114
+ mkdir! sftp, remote_root
115
+
116
+ # Check to see if there is a HTML version of this preview. Some emails
117
+ # do not have a hosted version and so it is not necessary to upload the
118
+ # HTML version of the email - but this is a bad practice.
119
+ file_name = view.file_name
120
+ unless file_name.blank?
121
+
122
+ remote_file_name = File.join(remote_root, file_name)
123
+ puts "Uploading #{remote_file_name}"
124
+
125
+ # We need to use StringIO to write the email to a buffer in order to upload
126
+ # the email's content in binary so that its encoding is honored. SFTP defaults
127
+ # to ASCII-8bit in non-binary mode, so it was blowing up on UTF-8 special
128
+ # characters (e.g. "Mäkinen").
129
+ # http://stackoverflow.com/questions/9439289/netsftp-transfer-mode-binary-vs-text
130
+ io = StringIO.new(view.render!)
131
+ sftp.upload!(io, remote_file_name)
132
+
133
+ end
134
+
135
+ # Upload the images to the remote directory
136
+ copy! sftp, local_images, remote_root, force && last_remote_root != remote_root
137
+ last_remote_root = remote_root
138
+
139
+ end
140
+
141
+ end
142
+
143
+ # Timestamp to indicate we uploaded now
144
+ email.set_meta :last_upload, Time.now.to_i
145
+
146
+ true
147
+ end
148
+
149
+ def self.mkdir! sftp, path
150
+
151
+ _path = File::SEPARATOR
152
+
153
+ path.split(File::SEPARATOR).each do |dir|
154
+
155
+ # Add the child directory on to the path.
156
+ _path = File.join(_path, dir)
157
+
158
+ begin
159
+ sftp.stat!(_path).directory?
160
+ rescue Net::SFTP::StatusException
161
+ begin
162
+ puts "Creating directory: #{_path}"
163
+ sftp.mkdir!(_path)
164
+ rescue
165
+ raise "Error creating #{_path}: #{$!}"
166
+ end
167
+ end
168
+ end
169
+
170
+ end
171
+
172
+ end
173
+ end