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,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