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,6 @@
1
+ This is the plain-text version of your email.
2
+
3
+ <% if production? %>
4
+ This email was sent to [email]. {a id="unsubscribe" href=#}Click here to unsubscribe{/a}.
5
+ Using ERB, this unsubscribe notice will only appear in the 'email' format of this project.
6
+ <% end %>
data/bin/inkcite ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'inkcite'
4
+ require 'inkcite/cli/base'
5
+
6
+ Inkcite::Cli::Base.start
data/inkcite.gemspec ADDED
@@ -0,0 +1,42 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'inkcite/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+
8
+ spec.name = "inkcite"
9
+ spec.version = Inkcite::VERSION
10
+ spec.platform = Gem::Platform::RUBY
11
+ spec.authors = ["Jeffrey D. Hoffman"]
12
+ spec.email = ["inkcite@inkceptional.com"]
13
+ spec.description = "An opinionated modern, responsive HTML email generator with integrated helpers, versioning, live previews, minification and testing."
14
+ spec.summary = "Simplifying email development"
15
+ spec.homepage = "https://github.com/inkceptional/inkcite"
16
+ spec.license = "MIT"
17
+
18
+ spec.files = `git ls-files`.split($/) - [".gitignore", "Gemfile", "Gemfile.lock"]
19
+ spec.executables << 'inkcite'
20
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
21
+ spec.require_paths = ["lib"]
22
+
23
+ # Sorry, not yet
24
+ spec.has_rdoc = false
25
+
26
+ spec.add_dependency 'activesupport'
27
+ spec.add_dependency 'builder'
28
+ spec.add_dependency 'erubis'
29
+ spec.add_dependency 'faker'
30
+ spec.add_dependency 'litmus'
31
+ spec.add_dependency 'mail'
32
+ spec.add_dependency 'net-sftp'
33
+ spec.add_dependency 'rack'
34
+ spec.add_dependency 'rubyzip'
35
+ spec.add_dependency 'thor'
36
+ spec.add_dependency 'yui-compressor'
37
+
38
+ spec.add_development_dependency "bundler", "~> 1.1"
39
+ spec.add_development_dependency "rake"
40
+ spec.add_development_dependency "minitest"
41
+
42
+ end
data/lib/inkcite.rb ADDED
@@ -0,0 +1,32 @@
1
+ require 'erubis'
2
+ require 'i18n'
3
+ require 'set'
4
+ require 'uri'
5
+ require 'yaml'
6
+
7
+ require 'active_support/core_ext/hash/keys.rb' # Symbolize keys!
8
+ require 'active_support/core_ext/module/delegation.rb'
9
+ require 'active_support/core_ext/object/blank'
10
+ require 'active_support/core_ext/object/to_query'
11
+ require 'active_support/core_ext/string/inflections'
12
+ require 'active_support/core_ext/string/starts_ends_with'
13
+
14
+ require 'inkcite/version'
15
+ require 'inkcite/email'
16
+ require 'inkcite/util'
17
+ require 'inkcite/view'
18
+ require 'inkcite/minifier'
19
+ require 'inkcite/parser'
20
+ require 'inkcite/renderer'
21
+
22
+ module Inkcite
23
+
24
+ def self.asset_path
25
+ File.expand_path('../../..', File.dirname(__FILE__))
26
+ end
27
+
28
+ end
29
+
30
+ # Make sure only available locales are used. This will be the default in the
31
+ # future but we need this to silence a deprecation warning from 0.6.9
32
+ I18n.config.enforce_available_locales = true
@@ -0,0 +1,128 @@
1
+ require 'thor'
2
+ require 'fileutils'
3
+
4
+ module Inkcite
5
+ module Cli
6
+ class Base < Thor
7
+
8
+ desc 'build [options]', 'Build the static email assets for deployment'
9
+ option :archive,
10
+ :aliases => '-a',
11
+ :desc => 'The name of the archive to compress final assets into'
12
+ option :force,
13
+ :aliases => '-f',
14
+ :desc => 'Build even if there are errors (not recommended)',
15
+ :type => :boolean
16
+
17
+ def build
18
+ require_relative 'build'
19
+ Cli::Build.invoke(email, {
20
+ :archive => options['archive'],
21
+ :force => options['force']
22
+ })
23
+ end
24
+
25
+ desc 'init NAME [options]', 'Initialize a new email project in the NAME directory'
26
+ option :from,
27
+ :aliases => '-f',
28
+ :desc => 'Clones an existing Inkcite project into a new one'
29
+
30
+ def init name
31
+ require_relative 'init'
32
+ Cli::Init.invoke(name, options)
33
+ end
34
+
35
+ desc 'preview TO [options]', 'Send a preview of the email recipient list: developer, internal or client'
36
+
37
+ def preview to=:developer
38
+ require_relative 'preview'
39
+ Cli::Preview.invoke(email, to, options)
40
+ end
41
+
42
+ desc 'server [options]', 'Start the preview server'
43
+ option :environment,
44
+ :aliases => '-e',
45
+ :default => 'development',
46
+ :desc => 'The environment Inkcite will run under'
47
+ option :format,
48
+ :aliases => '-f',
49
+ :default => 'email',
50
+ :desc => 'The format Inkcite will display - either email, browser or text'
51
+ method_option :host,
52
+ :type => :string,
53
+ :aliases => '-h',
54
+ :default => '0.0.0.0',
55
+ :desc => 'The ip address Inkcite will bind to'
56
+ method_option :port,
57
+ :aliases => '-p',
58
+ :default => '4567',
59
+ :desc => 'The port Inkcite will listen on',
60
+ :type => :numeric
61
+ option :version,
62
+ :aliases => '-v',
63
+ :desc => 'Render a specific version of the email'
64
+
65
+ def server
66
+ require_relative 'server'
67
+
68
+ Cli::Server.start(email, {
69
+ :environment => environment,
70
+ :format => format,
71
+ :host => options['host'],
72
+ :port => options['port'],
73
+ :version => version
74
+ })
75
+
76
+ end
77
+
78
+ desc 'test [options]', 'Tests (or re-tests) the email with Litmus'
79
+ option :new,
80
+ :aliases => '-n',
81
+ :desc => 'Forces a new test to be created, otherwise will revision an existing test if present',
82
+ :type => :boolean
83
+
84
+ def test
85
+ require_relative 'test'
86
+ Cli::Test.invoke(email, options)
87
+ end
88
+
89
+ desc 'upload', 'Upload the preview version to your CDN or remote image server'
90
+ option :force,
91
+ :aliases => '-f',
92
+ :desc => "Forces files to be uploaded regardless of whether or not they've changed",
93
+ :type => :boolean
94
+ def upload
95
+ options[:force] ? email.upload! : email.upload
96
+ end
97
+
98
+ private
99
+
100
+ # Resolves the desired environment (e.g. :development or :preview)
101
+ # from Thor's commandline options.
102
+ def environment
103
+ options['environment'] || :development
104
+ end
105
+
106
+ def email
107
+ Email.new(Dir.pwd)
108
+ end
109
+
110
+ # Resolves the desired format (e.g. :browser or :email) from Thor's
111
+ # commandline options.
112
+ def format
113
+ options['format'] || :email
114
+ end
115
+
116
+ # Resolves the desired version (typically blank or :default) from
117
+ # Thor's commandline options.
118
+ def version
119
+ options['version']
120
+ end
121
+
122
+ def view
123
+ email.view(environment, format, version)
124
+ end
125
+
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,130 @@
1
+ module Inkcite
2
+ module Cli
3
+ class Build
4
+
5
+ def self.invoke email, opts
6
+
7
+ errors = false
8
+
9
+ # Don't allow production files to be built if there are errors.
10
+ email.views(:production) do |ev|
11
+
12
+ ev.render!
13
+
14
+ if !ev.errors.blank?
15
+ puts "The #{ev.version} version (#{ev.format}) has #{ev.errors.size} errors:"
16
+ puts " - #{ev.errors.join("\n - ")}"
17
+ errors = true
18
+ end
19
+
20
+ end
21
+
22
+ abort("Fix errors or use --force to build") if errors && !opts[:force]
23
+
24
+ # First, compile all assets to the build directory.
25
+ build_to_dir email, opts
26
+
27
+ # No archive? Build to files instead.
28
+ archive = opts[:archive]
29
+ build_archive(email, opts) unless archive.blank?
30
+
31
+ end
32
+
33
+ private
34
+
35
+ # Configuration value controlling where the production files will
36
+ # be created
37
+ BUILD_PATH = :'build-path'
38
+
39
+ def self.build_archive email, opts
40
+
41
+ require 'zip'
42
+
43
+ # This is the fully-qualified path to the .zip file.
44
+ zip_file = File.expand_path(opts[:archive])
45
+ puts "Archiving to #{zip_file} ..."
46
+
47
+ # The Zip::File will try to update an existing archive so just blow the old
48
+ # one away if it still exists.
49
+ File.delete(zip_file) if File.exists?(zip_file)
50
+
51
+ # The absolute path to the build directories
52
+ build_html_to = build_path(email)
53
+ build_images_to = build_images_path(email)
54
+
55
+ Zip::File.open(zip_file, Zip::File::CREATE) do |zip|
56
+
57
+ # Add the minified images to the .zip archive
58
+ if File.exists?(build_images_to)
59
+ Dir.foreach(build_images_to) do |img|
60
+ img_path = File.join(build_images_to, img)
61
+ zip.add(File.join(Inkcite::Email::IMAGES, img), img_path) unless File.directory?(img_path)
62
+ end
63
+ end
64
+
65
+ Dir.foreach(build_html_to) do |file|
66
+ file_path = File.join(build_html_to, file)
67
+ zip.add(file, file_path)
68
+ end
69
+
70
+ end
71
+
72
+ # Remove the build directory
73
+ FileUtils.rm_rf(build_html_to)
74
+
75
+ end
76
+
77
+ def self.build_to_dir email, opts
78
+
79
+ # The absolute path to the build directories
80
+ build_html_to = build_path(email)
81
+ build_images_to = build_images_path(email)
82
+
83
+ puts "Building to #{build_html_to}"
84
+
85
+ # Sanity check to ensure we're not building to the same
86
+ # directory as we're working.
87
+ if File.identical?(email.path, build_html_to)
88
+ puts "Working path and build path can not be the same. Change the '#{BUILD_PATH}' value in your config.yml."
89
+ exit(1)
90
+ end
91
+
92
+ # Clear the existing build-to directory so we don't get any
93
+ # lingering files from the last build.
94
+ FileUtils.rm_rf build_html_to
95
+
96
+ # Remove any existing images directory and then create a new one to
97
+ # ensure the entire build path exists.
98
+ FileUtils.mkpath build_images_to
99
+
100
+ # Check to see if images should be optimized and if so, perform said
101
+ # optimization on new or updated images.
102
+ email.optimize_images if email.optimize_images?
103
+
104
+ # For each of the production views, build the HTML and links files.
105
+ email.views(:production) do |ev|
106
+
107
+ File.open(File.join(build_html_to, ev.file_name), 'w') { |f| ev.write(f) }
108
+
109
+ # Tracked link CSV
110
+ File.open(File.join(build_html_to, ev.links_file_name), 'w') { |f| ev.write_links_csv(f) } if ev.track_links?
111
+
112
+ end
113
+
114
+ # Copy all of the source images into the build directory in preparation
115
+ # for optimization
116
+ FileUtils.cp_r(File.join(email.optimized_image_dir, '.'), build_images_to)
117
+
118
+ end
119
+
120
+ def self.build_path email
121
+ File.expand_path email.config[BUILD_PATH] || 'build'
122
+ end
123
+
124
+ def self.build_images_path email
125
+ File.join(build_path(email), Email::IMAGES)
126
+ end
127
+
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,58 @@
1
+ module Inkcite
2
+ module Cli
3
+ class Init
4
+
5
+ def self.invoke path, opts
6
+
7
+ full_init_path = File.expand_path(path)
8
+
9
+ # Sanity check to make sure we're not writing over an existing
10
+ # Inkcite project.
11
+ abort "It appears that an Inkcite already exists in #{path}" if File.exists?(File.join(full_init_path, 'config.yml'))
12
+
13
+ init_image_path = File.join(path, Inkcite::Email::IMAGES)
14
+ full_init_image_path = File.join(full_init_path, Inkcite::Email::IMAGES)
15
+
16
+ # Create the images directory first because it's the deepest level
17
+ # of the project structure.
18
+ FileUtils.mkpath(full_init_image_path)
19
+
20
+ puts "Created #{init_image_path}"
21
+
22
+ # Check to see if the user specified a --from path that is used to
23
+ # clone an existing project rather than init a new one.
24
+ from_path = opts[:from]
25
+
26
+ # True if we're initializing a project from the built-in files.
27
+ is_new = opts[:from].blank?
28
+
29
+ # Use the default, bundled path if a from-path wasn't specified.
30
+ # Verify the path exists
31
+ from_path = File.join(File.expand_path('../../..', File.dirname(__FILE__)), 'assets', 'init') if is_new
32
+
33
+ # Verify that the source directory contains the config.yml file
34
+ # signifying an existing Inkcite project.
35
+ abort "Can't find #{from_path} or it isn't an existing Inkcite project" unless File.exists?(File.join(from_path, 'config.yml'))
36
+
37
+ # Copy the main Inkcite project files
38
+ FILES.each do |file|
39
+ FileUtils.cp File.join(from_path, file), full_init_path
40
+ puts "Created #{File.join(path, file)}"
41
+ end
42
+
43
+ # Check to see if there are images and copy those as well.
44
+ from_path = File.join(from_path, Inkcite::Email::IMAGES)
45
+ if File.exists?(from_path)
46
+ FileUtils.cp_r(File.join(from_path, '.'), full_init_image_path)
47
+ puts "Copied images to #{init_image_path}"
48
+ end
49
+
50
+ end
51
+
52
+ private
53
+
54
+ FILES = %w( config.yml source.html helpers.tsv source.txt )
55
+
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,30 @@
1
+ require 'inkcite/mailer'
2
+
3
+ module Inkcite
4
+ module Cli
5
+ class Preview
6
+
7
+ def self.invoke email, to, opt
8
+
9
+ # Push the browser preview(s) up to the server to ensure that the
10
+ # latest images and "view in browser" versions are available.
11
+ email.upload
12
+
13
+ puts "Sending preview to #{to} ..."
14
+
15
+ case to.to_sym
16
+ when :client
17
+ Inkcite::Mailer.client(email)
18
+ when :internal
19
+ Inkcite::Mailer.internal(email)
20
+ when :developer
21
+ Inkcite::Mailer.developer(email)
22
+ else
23
+ raise "Invalid preview distribution target"
24
+ end
25
+
26
+ end
27
+
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,123 @@
1
+ require 'webrick'
2
+ require 'rack'
3
+ require 'socket'
4
+
5
+ module Inkcite
6
+ module Cli
7
+ class Server
8
+
9
+ def self.start email, opts
10
+
11
+ # Port should always be an integer.
12
+ port = opts[:port].to_i
13
+ host = opts[:host]
14
+
15
+ # Resolve local public IP for mobile device address
16
+ ip = begin
17
+ IPSocket.getaddress(Socket.gethostname)
18
+ rescue
19
+ nil
20
+ end
21
+
22
+ puts "Inkcite #{Inkcite::VERSION} is starting up ..."
23
+
24
+ begin
25
+ @server = ::WEBrick::HTTPServer.new({
26
+ :BindAddress => host,
27
+ :Port => port,
28
+ :AccessLog => [],
29
+ :Logger => WEBrick::Log.new(nil, 0)
30
+ })
31
+ rescue Errno::EADDRINUSE
32
+ raise "== Port #{port} is unavailable. Either close the instance of Inkcite already running on #{port} or start this Inkcite instance on a new port with: --port=#{port+1}"
33
+ exit(1)
34
+ end
35
+
36
+ # Listen to all of the things in order to properly
37
+ # shutdown the server.
38
+ %w(INT HUP TERM QUIT).each do |sig|
39
+ if Signal.list[sig]
40
+ Signal.trap(sig) do
41
+ @server.shutdown
42
+ end
43
+ end
44
+ end
45
+
46
+ @server.mount "/", Rack::Handler::WEBrick, Inkcite::Cli::Server.new(email, opts)
47
+
48
+ puts "Your email is being served at http://#{host}:#{port}"
49
+ puts "Point your mobile device to http://#{ip}:#{port}" if ip
50
+
51
+ @server.start
52
+
53
+ end
54
+
55
+ def initialize email, opts
56
+ @email = email
57
+ @opts = opts
58
+ end
59
+
60
+ def call env
61
+ begin
62
+
63
+ path = env[REQUEST_PATH]
64
+
65
+ # If this request is for the root index page, render the email. Otherwise
66
+ # render the static asset.
67
+ if path == REQUEST_ROOT
68
+
69
+ # Check for and convert query string parameters to a symolized
70
+ # key hash so the designer can override the environment, format
71
+ # and version attributes during a given rendering.
72
+ # Courtesy of http://stackoverflow.com/questions/21990997/how-do-i-create-a-hash-from-a-querystring-in-ruby
73
+ params = CGI::parse(env[QUERY_STRING] || '')
74
+ params = Hash[params.map { |key, values| [ key.to_sym, values[0] || true ] }].symbolize_keys
75
+
76
+ # Allow the designer to specify both short- and long-form versions of
77
+ # the (e)nvironment, (f)ormat and (v)ersion. Otherwise, use the values
78
+ # that Inkcite was started with.
79
+ environment = Util.detect(params[:e], params[:environment], @opts[:environment])
80
+ format = Util.detect(params[:f], params[:format], @opts[:format])
81
+ version = Util.detect(params[:v], params[:view], @opts[:version])
82
+
83
+ # Timestamp all of the messages from this rendering so it is clear which
84
+ # messages are associated with this reload.
85
+ ts = "[#{Time.now.strftime(DATEFORMAT)}]"
86
+
87
+ puts ''
88
+ puts "#{ts} Rendering your email [environment=#{environment}, format=#{format}, version=#{version || 'default'}]"
89
+
90
+ view = @email.view(environment, format, version)
91
+
92
+ html = view.render!
93
+
94
+ unless view.errors.blank?
95
+ error_count = view.errors.count
96
+ puts "#{ts} #{error_count} error#{'s' if error_count > 1} or warning#{'s' if error_count > 1}:"
97
+ puts "#{ts} - #{view.errors.join("\n#{ts} - ")}"
98
+ end
99
+
100
+ [200, {}, [html]]
101
+ else
102
+ Rack::File.new(Dir.pwd).call(env)
103
+
104
+ end
105
+
106
+ rescue Exception => e
107
+ puts e.message
108
+ puts e.backtrace.inspect
109
+ end
110
+
111
+ end
112
+
113
+ private
114
+
115
+ REQUEST_PATH = 'REQUEST_PATH'
116
+ REQUEST_ROOT = '/'
117
+ QUERY_STRING = 'QUERY_STRING'
118
+
119
+ DATEFORMAT = '%Y-%m-%d %H:%M:%S'
120
+
121
+ end
122
+ end
123
+ end