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,95 @@
1
+ module Inkcite
2
+ module Renderer
3
+ class Image < ImageBase
4
+
5
+ def render tag, opt, ctx
6
+
7
+ img = Element.new('img', { :border => 0 })
8
+
9
+ # Ensure that height and width are defined in the image's attributes.
10
+ mix_dimensions img, opt
11
+
12
+ # Get the fully-qualified URL to the image or placeholder image if it's
13
+ # missing from the images directory.
14
+ img[:src] = image_url(opt[:src], opt, ctx)
15
+
16
+ mix_background img, opt
17
+
18
+ # Check to see if there is alt text specified for this image. We are
19
+ # testing against nil because sometimes the author desires an empty
20
+ # alt-text attribute.
21
+ alt = opt[:alt]
22
+ if alt
23
+
24
+ # Ensure that the alt-tag has quotes around it.
25
+ img[:alt] = quote(alt)
26
+
27
+ # The rest of this logic is only appropriate if the alt text
28
+ # is not blank.
29
+ unless alt.blank?
30
+
31
+ # Copy the text to the title attribute if enabled for this issue
32
+ img[:title] = img[:alt] if ctx.is_enabled?(COPY_ALT_TO_TITLE)
33
+
34
+ # All images with alt text inherit small font unless otherwise specified.
35
+ opt[:font] ||= 'small'
36
+
37
+ mix_font img, opt, ctx
38
+
39
+ end
40
+
41
+ end
42
+
43
+ # Images default to block display to prevent unexpected margins in Gmail
44
+ # http://www.campaignmonitor.com/blog/post/3132/how-to-stop-gmail-from-adding-a-margin-to-your-images/
45
+ display = opt[:display] || BLOCK
46
+ img.style[:display] = display unless display == DEFAULT
47
+
48
+ # True if the designer wants this image to flow inline. When true it
49
+ # vertically aligns the image with the text.
50
+ inline = (display == INLINE)
51
+
52
+ align = opt[:align] || ('absmiddle' if inline)
53
+ img[:align] = align unless align.blank?
54
+
55
+ valign = opt[:valign] || ('middle' if inline)
56
+ img.style[VERTICAL_ALIGN] = valign unless valign.blank?
57
+
58
+ mobile_src = opt[:'mobile-src']
59
+ unless mobile_src.blank?
60
+
61
+ # Get a unique CSS class name that will be used to swap in the alternate
62
+ # image on mobile.
63
+ klass = klass_name(mobile_src, ctx)
64
+
65
+ # Fully-qualify the image URL.
66
+ mobile_src = image_url(mobile_src, opt, ctx)
67
+
68
+ # Add a responsive rule that replaces the image with a different source
69
+ # with the same dimensions. Warning, this isn't supported on earlier
70
+ # versions of iOS 6 and Android 4.
71
+ # http://www.emailonacid.com/forum/viewthread/404/
72
+ ctx.media_query << img.add_rule(Rule.new(tag, klass, "content: url(#{mobile_src}) !important;"))
73
+
74
+ end
75
+
76
+ mobile = opt[:mobile]
77
+
78
+ # Check to see if this image is inside of a mobile-image declaration.
79
+ # If so, the image defaults to hide on mobile.
80
+ mobile = HIDE if mobile.blank? && !ctx.parent_opts(:mobile_image).blank?
81
+
82
+ mix_responsive img, opt, ctx, mobile
83
+
84
+ img.to_s
85
+ end
86
+
87
+ private
88
+
89
+ # Name of the property controlling whether or not the alt text should
90
+ # be copied to the title field.
91
+ COPY_ALT_TO_TITLE = :'copy-alt-to-title'
92
+
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,82 @@
1
+ module Inkcite
2
+ module Renderer
3
+ class ImageBase < Responsive
4
+
5
+ protected
6
+
7
+ # Display mode constants
8
+ BLOCK = 'block'
9
+ DEFAULT = 'default'
10
+ INLINE = 'inline'
11
+
12
+ def image_url _src, opt, ctx
13
+
14
+ src = _src
15
+
16
+ # True if dimensions are missing.
17
+ missing_dimensions = missing_dimensions?(opt)
18
+
19
+ # Fully-qualify the image path for this version of the email unless it
20
+ # is already includes a full address.
21
+ unless src.include?('://')
22
+
23
+ # Verify that the image exists.
24
+ if ctx.assert_image_exists(src) || ctx.is_disabled?(Inkcite::Email::IMAGE_PLACEHOLDERS)
25
+
26
+ if missing_dimensions
27
+ # TODO read the image dimensions from the file and auto-populate
28
+ # the width and height fields.
29
+ end
30
+
31
+ # Convert the source (e.g. "cover.jpg") into a fully-qualified reference
32
+ # to the image. In development this may be images/cover.jpg but in the
33
+ # other environments this would likely be a full URL to the image where it
34
+ # is being hosted.
35
+ src = ctx.image_url(src)
36
+
37
+ elsif DIMENSIONS.all? { |dim| opt[dim].to_i > MINIMUM_DIMENSION_FOR_PLACEHOLDER }
38
+
39
+ # As a convenience, replace missing images with placehold.it as long as they
40
+ # meet the minimum dimensions. No need to spam the design with tiny, tiny
41
+ # placeholders.
42
+ src = "http://placehold.it/#{opt[:width]}x#{opt[:height]}#{File.extname(src)}"
43
+
44
+ # Check to see if the designer specified FPO text for this placeholder -
45
+ # otherwise default to the dimensions of the image.
46
+ fpo = opt[:fpo]
47
+ src << "&text=#{URI::encode(fpo)}" unless fpo.blank?
48
+
49
+ end
50
+
51
+ end
52
+
53
+ # Don't let an image go into production without dimensions. Using the original
54
+ # src so that we don't display the verbose qualified URL to the developer.
55
+ ctx.error('Missing image dimensions', { :src => _src }) if missing_dimensions
56
+
57
+ quote(src)
58
+ end
59
+
60
+ def klass_name src, ctx
61
+ klass = "i%02d" % ctx.unique_id(:i)
62
+ end
63
+
64
+ def missing_dimensions? att
65
+ DIMENSIONS.any? { |dim| att[dim].to_i <= 0 }
66
+ end
67
+
68
+ def mix_dimensions img, opt
69
+ DIMENSIONS.each { |dim| img[dim] = opt[dim].to_i }
70
+ end
71
+
72
+ private
73
+
74
+ # Both the height and width of the image must exceed this amount in order
75
+ # to get a placehold.it automatically inserted. Otherwise only an error
76
+ # is raised for missing images.
77
+ MINIMUM_DIMENSION_FOR_PLACEHOLDER = 25
78
+
79
+ end
80
+ end
81
+ end
82
+
@@ -0,0 +1,38 @@
1
+
2
+ module Inkcite
3
+ module Renderer
4
+ class InBrowser < Base
5
+
6
+ def render tag, opt, ctx
7
+
8
+ # You can only view in-browser if we're viewing an email.
9
+ return nil unless ctx.email?
10
+
11
+ url = ctx[Inkcite::Email::VIEW_IN_BROWSER_URL]
12
+ return nil if url.blank?
13
+
14
+ browser_view = ctx.email.view(ctx.environment, :browser, ctx.version)
15
+
16
+ # Make sure we're converting any embedded values in the host URL
17
+ url = Renderer.render(url, browser_view)
18
+
19
+ # Optional link override color.
20
+ color = opt[:color]
21
+
22
+ # Optional call-to-action override - otherwise defaults to view in browser.
23
+ cta = opt[:cta] || 'View in Browser'
24
+
25
+ id = opt[:id] || 'in-browser'
26
+
27
+ html = "{a id=\"#{id}\" href=\"#{url}\""
28
+ html << " color=\"#{color}\"" unless color.blank?
29
+ html << '}'
30
+ html << cta
31
+ html << '{/a}'
32
+
33
+ html
34
+ end
35
+
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,73 @@
1
+ module Inkcite
2
+ module Renderer
3
+ class Like < Base
4
+
5
+ def render tag, opt, ctx
6
+
7
+ # Handle the case where we're building the hosted version of the email and
8
+ # JavaScript is used to trigger the Facebook like dialog.
9
+ if ctx.browser?
10
+
11
+ return '{/a}' if tag == '/like'
12
+
13
+ page = opt[:page]
14
+ if page.blank?
15
+ ctx.error("Like tag missing 'page' attribute")
16
+
17
+ else
18
+
19
+ brand = opt[:brand] || 'Us'
20
+
21
+ # Add an externally-hosted script to the context.
22
+ ctx.scripts << URI.parse('http://ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js')
23
+
24
+ # Add the java script to be embedded.
25
+ ctx.scripts << URI.parse('file://facebook-like.js')
26
+
27
+ # Add the Facebook like stylesheet.
28
+ ctx.styles << URI.parse('file://facebook-like.css')
29
+
30
+ ctx.footer << <<-eos
31
+ <div id="dialog-wrap">
32
+ <div id="dialog">
33
+ <h2>Like #{brand} on Facebook</h2>
34
+ <div id="dialog-content" class='loading'>
35
+ <div class="fb-like" data-href="http://www.facebook.com/#{page}" data-send="true" data-height="100" data-width="450" data-show-faces="true"></div>
36
+ </div>
37
+ <div id="dialog-buttons">
38
+ <a href=# onclick="return closeLike();"><span>Close</span></a>
39
+ </div>
40
+ </div>
41
+ </div>
42
+
43
+ <div id="fb-root"></div>
44
+ eos
45
+
46
+ end
47
+
48
+ '{a href=# onclick="return openLike();"}'
49
+
50
+ else
51
+
52
+ url = ctx[Inkcite::Email::VIEW_IN_BROWSER_URL]
53
+ unless url.blank?
54
+
55
+ return '{/a}' if tag == '/like'
56
+
57
+ # Otherwise, link to the hosted version of the email with the like hash tag
58
+ # to trigger like automatically on arrival.
59
+ href = Inkcite::Renderer.render(ctx[Inkcite::Email::VIEW_IN_BROWSER_URL] + '#like', ctx)
60
+
61
+ id = opt[:id]
62
+
63
+ "{a id=\"#{id}\" href=\"#{href}\"}"
64
+ end
65
+
66
+ end
67
+
68
+ end
69
+
70
+ end
71
+ end
72
+ end
73
+
@@ -0,0 +1,243 @@
1
+ module Inkcite
2
+ module Renderer
3
+ class Link < Responsive
4
+
5
+ def render tag, opt, ctx
6
+
7
+ tag_stack = ctx.tag_stack(:a)
8
+
9
+ if tag == '/a'
10
+
11
+ # Grab the attributes of the opening tag.
12
+ opening = tag_stack.pop
13
+
14
+ # Nothing to do in the
15
+ return '' if ctx.text?
16
+
17
+ html = '</a>'
18
+
19
+ # Check to see if the declaration has been marked as a block
20
+ # element and if so, close the div.
21
+ html << '</div>' if opening[:block]
22
+
23
+ return html
24
+ end
25
+
26
+ # Push the link's options onto the tag stack so that we can have
27
+ # access to its attributes when we close it.
28
+ tag_stack << opt
29
+
30
+ # Get the currently open table cell and see if link color is
31
+ # overridden in there.
32
+ td_parent = ctx.tag_stack(:td).opts
33
+ table_parent = ctx.tag_stack(:table).opts
34
+
35
+ # Choose a color from the parameters or inherit from the parent td, table or context.
36
+ opt[:color] = detect(opt[:color], td_parent[:link], table_parent[:link], ctx[LINK_COLOR])
37
+
38
+ a = Element.new('a')
39
+
40
+ mix_font a, opt, ctx
41
+
42
+ id = opt[:id]
43
+ href = opt[:href]
44
+
45
+ # True if the href is missing. If so, we may try to look it up by it's ID
46
+ # or we'll insert a default TBD link.
47
+ missing = href.blank?
48
+
49
+ # True if it's a link deeper into the content.
50
+ hash = !missing && href.starts_with?(POUND_SIGN)
51
+
52
+ # True if this is a mailto link.
53
+ mailto = !missing && !hash && href.starts_with?(MAILTO)
54
+
55
+ # Only perform special processing on the link if it's TBD or not a link to
56
+ # something in the page.
57
+ unless hash || mailto
58
+
59
+ if id.blank?
60
+
61
+ # Generate a placeholder ID and warn the user about it.
62
+ id = "link#{ctx.links.size + 1}"
63
+ ctx.error 'Link missing ID', { :href => href }
64
+
65
+ else
66
+
67
+ # Check to see if we've encountered an auto-incrementing link ID (e.g. event++)
68
+ # Replace the ++ with a unique count for this ID prefix.
69
+ id = id.gsub(PLUS_PLUS, ctx.unique_id(id).to_s) if id.end_with?(PLUS_PLUS)
70
+
71
+ end
72
+
73
+ # Get the HREF that we have previously encountered for this ID. When not blank
74
+ # we'll sanity check that the URL is the same.
75
+ last_href = ctx.links[id]
76
+
77
+ if missing
78
+
79
+ # If we don't have a URL, check to see if we've encountered this
80
+ href = last_href || ctx[MISSING_LINK_HREF]
81
+
82
+ ctx.error "Link missing href", { :id => id } unless last_href
83
+
84
+ else
85
+
86
+ # Optionally tag the link's query string for post-send log analytics.
87
+ href = add_tagging(id, href, ctx)
88
+
89
+ if last_href.blank?
90
+
91
+ # Associate the href with it's ID in case we bump into this link again.
92
+ ctx.links[id] = href
93
+
94
+ elsif last_href != href
95
+
96
+ # It saves everyone a lot of time if you alert them that an ID appears multiple times
97
+ # in the email and with mismatched URLs.
98
+ ctx.error "Link href mismatch", { :id => id, :expected => last_href, :found => href }
99
+
100
+ end
101
+
102
+ end
103
+
104
+ # Optionally replace the href with an ESP trackable url. Gotta do this after
105
+ # the link has been stored in the context because we don't want trackable
106
+ # URLs interfering with the links file.
107
+ href = add_tracking(id, href, ctx)
108
+
109
+ a[:target] = BLANK
110
+
111
+ end
112
+
113
+ # Make sure that these types of links have quotes.
114
+ href = quote(href) unless ctx.text?
115
+
116
+ # Set the href attribute to the resolved href.
117
+ a[:href] = href
118
+
119
+ # Links never get any text decoration.
120
+ a.style[TEXT_DECORATION] = NONE
121
+
122
+ if ctx.browser?
123
+
124
+ # Programmatically we can install onclick listeners for hosted versions.
125
+ # Check to see if one is specified and the Javascript is permitted in
126
+ # this version.
127
+ onclick = opt[:onclick]
128
+ a[:onclick] = quote(onclick) unless onclick.blank?
129
+
130
+ end
131
+
132
+ html = ''
133
+
134
+ if ctx.text?
135
+ html << a[:href]
136
+
137
+ else
138
+
139
+ klass = opt[:class]
140
+ a.classes << klass unless klass.blank?
141
+
142
+ mix_responsive a, opt, ctx
143
+
144
+ # Some responsive modes (e.g. button) change the display type from in-line
145
+ # to block. This change can cause unexpected whitespace or other unexpected
146
+ # layout changes. Outlook doesn't support block display on link elements
147
+ # so the best workaround is simply to wrap the element in <div> tags.
148
+ if a.responsive_styles.any?(&:block?)
149
+ html << '<div>'
150
+
151
+ # Remember that we made this element block-display so that we can append
152
+ # the extra div when we close the tag.
153
+ opt[:block] = true
154
+
155
+ end
156
+
157
+ html << a.to_s
158
+
159
+ end
160
+
161
+ html
162
+ end
163
+
164
+ private
165
+
166
+ # Property controlling where missing links are pointed.
167
+ MISSING_LINK_HREF = :'missing-link-url'
168
+
169
+ # The configuration name of the field that holds the query parameter that
170
+ # will be tacked onto the end of all links.
171
+ TAG_LINKS = :'tag-links'
172
+
173
+ # The configuration name of the field that holds the domain name(s) for
174
+ # links that will be tagged.
175
+ TAG_LINKS_DOMAIN = :'tag-links-domain'
176
+
177
+ # The property name used to indicate that links in this email should be
178
+ # replaced with [trackable URLs].
179
+ TRACK_LINKS = :'track-links'
180
+
181
+ # Value to open links in a new window.
182
+ BLANK = '_blank'
183
+
184
+ MAILTO = 'mailto:'
185
+
186
+ # Signifies an auto-incrementing link ID.
187
+ PLUS_PLUS = '++'
188
+
189
+ def add_tagging id, href, ctx
190
+
191
+ # Check to see if we're tagging links.
192
+ tag = ctx[TAG_LINKS]
193
+ unless tag.blank?
194
+
195
+ # Blank tag domain means tag all the links - otherwise, make sure the
196
+ # href matches the desired domain name.
197
+ tag_domain = ctx[TAG_LINKS_DOMAIN]
198
+ if tag_domain.blank? || href =~ /^https?:\/\/[^\/]*#{tag_domain}/
199
+
200
+ # Prepend it with a question mark or an ampersand depending on the current
201
+ # state of the lin.
202
+ stag = href.include?('?') ? '&' : '?'
203
+ stag << replace_tag(tag, id, ctx)
204
+
205
+ # Inject before the pound sign if present - otherwise, just tack it on
206
+ # to the end of the href.
207
+ if hash = href.index(POUND_SIGN)
208
+ href[hash..0] = stag
209
+ else
210
+ href << stag
211
+ end
212
+
213
+ end
214
+
215
+ end
216
+
217
+ href
218
+ end
219
+
220
+ def add_tracking id, href, ctx
221
+
222
+ # Check to see if a trackable link string has been defined.
223
+ tracking = ctx[Inkcite::Email::TRACK_LINKS]
224
+
225
+ # Replace the fully-qualified URL with a tracking tag - presuming that the
226
+ # ESP will replace this href with it's own trackable URL at deployment.
227
+ href = URI.encode(replace_tag(tracking, id, ctx)) unless tracking.blank?
228
+
229
+ href
230
+ end
231
+
232
+ def replace_tag tag, id, ctx
233
+
234
+ # Inject the link's ID into the tag - that's the only value that can't
235
+ # be resolved from the context.
236
+ tag = tag.gsub('{id}', id)
237
+
238
+ Inkcite::Renderer.render(tag, ctx)
239
+ end
240
+
241
+ end
242
+ end
243
+ end