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