roadie 2.4.3 → 3.0.0.pre1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (87) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +3 -0
  3. data/.travis.yml +9 -14
  4. data/.yardopts +1 -1
  5. data/Changelog.md +22 -10
  6. data/Gemfile +3 -0
  7. data/Guardfile +11 -1
  8. data/README.md +165 -163
  9. data/Rakefile +2 -19
  10. data/lib/roadie.rb +14 -69
  11. data/lib/roadie/asset_provider.rb +7 -58
  12. data/lib/roadie/asset_scanner.rb +92 -0
  13. data/lib/roadie/document.rb +103 -0
  14. data/lib/roadie/errors.rb +57 -0
  15. data/lib/roadie/filesystem_provider.rb +21 -62
  16. data/lib/roadie/inliner.rb +71 -218
  17. data/lib/roadie/markup_improver.rb +88 -0
  18. data/lib/roadie/null_provider.rb +13 -0
  19. data/lib/roadie/null_url_rewriter.rb +12 -0
  20. data/lib/roadie/provider_list.rb +67 -0
  21. data/lib/roadie/rspec.rb +1 -0
  22. data/lib/roadie/rspec/asset_provider.rb +49 -0
  23. data/lib/roadie/selector.rb +42 -18
  24. data/lib/roadie/style_block.rb +33 -0
  25. data/lib/roadie/style_properties.rb +29 -0
  26. data/lib/roadie/style_property.rb +93 -0
  27. data/lib/roadie/stylesheet.rb +65 -0
  28. data/lib/roadie/url_generator.rb +126 -0
  29. data/lib/roadie/url_rewriter.rb +84 -0
  30. data/lib/roadie/version.rb +1 -1
  31. data/roadie.gemspec +6 -10
  32. data/spec/fixtures/big_em.css +1 -0
  33. data/spec/fixtures/stylesheets/green.css +1 -0
  34. data/spec/integration_spec.rb +125 -95
  35. data/spec/lib/roadie/asset_scanner_spec.rb +153 -0
  36. data/spec/lib/roadie/css_not_found_spec.rb +16 -0
  37. data/spec/lib/roadie/document_spec.rb +123 -0
  38. data/spec/lib/roadie/filesystem_provider_spec.rb +25 -72
  39. data/spec/lib/roadie/inliner_spec.rb +105 -537
  40. data/spec/lib/roadie/markup_improver_spec.rb +78 -0
  41. data/spec/lib/roadie/null_provider_spec.rb +21 -0
  42. data/spec/lib/roadie/null_url_rewriter_spec.rb +19 -0
  43. data/spec/lib/roadie/provider_list_spec.rb +81 -0
  44. data/spec/lib/roadie/selector_spec.rb +7 -5
  45. data/spec/lib/roadie/style_block_spec.rb +35 -0
  46. data/spec/lib/roadie/style_properties_spec.rb +61 -0
  47. data/spec/lib/roadie/style_property_spec.rb +82 -0
  48. data/spec/lib/roadie/stylesheet_spec.rb +41 -0
  49. data/spec/lib/roadie/test_provider_spec.rb +29 -0
  50. data/spec/lib/roadie/url_generator_spec.rb +120 -0
  51. data/spec/lib/roadie/url_rewriter_spec.rb +79 -0
  52. data/spec/shared_examples/asset_provider.rb +11 -0
  53. data/spec/shared_examples/url_rewriter.rb +23 -0
  54. data/spec/spec_helper.rb +5 -60
  55. data/spec/support/have_node_matcher.rb +2 -2
  56. data/spec/support/have_selector_matcher.rb +1 -1
  57. data/spec/support/have_styling_matcher.rb +48 -14
  58. data/spec/support/test_provider.rb +13 -0
  59. metadata +73 -177
  60. data/Appraisals +0 -15
  61. data/gemfiles/rails_3.0.gemfile +0 -7
  62. data/gemfiles/rails_3.0.gemfile.lock +0 -123
  63. data/gemfiles/rails_3.1.gemfile +0 -7
  64. data/gemfiles/rails_3.1.gemfile.lock +0 -126
  65. data/gemfiles/rails_3.2.gemfile +0 -7
  66. data/gemfiles/rails_3.2.gemfile.lock +0 -124
  67. data/gemfiles/rails_4.0.gemfile +0 -7
  68. data/gemfiles/rails_4.0.gemfile.lock +0 -119
  69. data/lib/roadie/action_mailer_extensions.rb +0 -95
  70. data/lib/roadie/asset_pipeline_provider.rb +0 -28
  71. data/lib/roadie/css_file_not_found.rb +0 -22
  72. data/lib/roadie/railtie.rb +0 -39
  73. data/lib/roadie/style_declaration.rb +0 -42
  74. data/spec/fixtures/app/assets/stylesheets/integration.css +0 -10
  75. data/spec/fixtures/public/stylesheets/integration.css +0 -10
  76. data/spec/fixtures/views/integration_mailer/marketing.html.erb +0 -2
  77. data/spec/fixtures/views/integration_mailer/notification.html.erb +0 -8
  78. data/spec/fixtures/views/integration_mailer/notification.text.erb +0 -6
  79. data/spec/lib/roadie/action_mailer_extensions_spec.rb +0 -227
  80. data/spec/lib/roadie/asset_pipeline_provider_spec.rb +0 -65
  81. data/spec/lib/roadie/css_file_not_found_spec.rb +0 -29
  82. data/spec/lib/roadie/style_declaration_spec.rb +0 -49
  83. data/spec/lib/roadie_spec.rb +0 -101
  84. data/spec/shared_examples/asset_provider_examples.rb +0 -11
  85. data/spec/support/anonymous_mailer.rb +0 -21
  86. data/spec/support/change_url_options.rb +0 -5
  87. data/spec/support/parse_styling.rb +0 -25
@@ -0,0 +1,126 @@
1
+ require 'set'
2
+
3
+ module Roadie
4
+ # @api private
5
+ # Class that handles URL generation
6
+ #
7
+ # URL generation is all about converting relative URLs into absolute URLS
8
+ # according to the given options. It is written such as absolute URLs will
9
+ # get passed right through, so all URLs could be passed through here.
10
+ class UrlGenerator
11
+ attr_reader :url_options
12
+
13
+ # Create a new instance with the given URL options.
14
+ #
15
+ # Initializing without a host setting raises an error, as do unknown keys.
16
+ #
17
+ # @param [Hash] url_options
18
+ # @option url_options [String] :host (required)
19
+ # @option url_options [String, Integer] :port
20
+ # @option url_options [String] :path root path
21
+ # @option url_options [String] :scheme URL scheme ("http" is default)
22
+ # @option url_options [String] :protocol alias for :scheme
23
+ def initialize(url_options)
24
+ raise ArgumentError, "No URL options were specified" unless url_options
25
+ raise ArgumentError, "No :host was specified; options are: #{url_options.inspect}" unless url_options[:host]
26
+ validate_options url_options
27
+
28
+ @url_options = url_options
29
+ @root_uri = build_root_uri
30
+ end
31
+
32
+ # Generate an absolute URL from a relative URL.
33
+ #
34
+ # If the passed path is already an absolute URL, it will be returned as-is.
35
+ # If passed a blank path, the "root URL" will be returned. The root URL is
36
+ # the URL that the {#url_options} would generate by themselves.
37
+ #
38
+ # An optional base can be specified. The base is another relative path from
39
+ # the root that specifies an "offset" from which the path was found in. A
40
+ # common use-case is to convert a relative path found in a stylesheet which
41
+ # resides in a subdirectory.
42
+ #
43
+ # @example Normal conversions
44
+ # generator = Roadie::UrlGenerator.new host: "foo.com", scheme: "https"
45
+ # generator.generate_url("bar.html") # => "https://foo.com/bar.html"
46
+ # generator.generate_url("/bar.html") # => "https://foo.com/bar.html"
47
+ # generator.generate_url("") # => "https://foo.com"
48
+ #
49
+ # @example Conversions with a base
50
+ # generator = Roadie::UrlGenerator.new host: "foo.com", scheme: "https"
51
+ # generator.generate_url("../images/logo.png", "/css") # => "https://foo.com/images/logo.png"
52
+ # generator.generate_url("../images/logo.png", "/assets/css") # => "https://foo.com/assets/images/logo.png"
53
+ #
54
+ # @param [String] base The base which the relative path comes from
55
+ # @return [String] an absolute URL
56
+ def generate_url(path, base = "/")
57
+ return root_uri.to_s if path.nil? or path.empty?
58
+ return path if path_is_absolute?(path)
59
+
60
+ combine_segments(root_uri, base, path).to_s
61
+ end
62
+
63
+ private
64
+ attr_reader :root_uri
65
+
66
+ def build_root_uri
67
+ path = make_absolute url_options[:path]
68
+ port = parse_port url_options[:port]
69
+ scheme = normalize_scheme(url_options[:scheme] || url_options[:protocol])
70
+ URI::Generic.build(scheme: scheme, host: url_options[:host], port: port, path: path)
71
+ end
72
+
73
+ def combine_segments(root, base, path)
74
+ new_path = apply_base(base, path)
75
+ if root.path
76
+ new_path = File.join(root.path, new_path)
77
+ end
78
+ root.merge(new_path)
79
+ end
80
+
81
+ def apply_base(base, path)
82
+ if path[0] == "/"
83
+ path
84
+ else
85
+ File.join(base, path)
86
+ end
87
+ end
88
+
89
+ # Strip :// from any scheme, if present
90
+ def normalize_scheme(scheme)
91
+ return 'http' unless scheme
92
+ scheme.to_s[/^\w+/]
93
+ end
94
+
95
+ def parse_port(port)
96
+ (port ? port.to_i : port)
97
+ end
98
+
99
+ def make_absolute(path)
100
+ if path.nil? || path[0] == "/"
101
+ path
102
+ else
103
+ "/#{path}"
104
+ end
105
+ end
106
+
107
+ def path_is_absolute?(path)
108
+ not parse_path(path).relative?
109
+ end
110
+
111
+ def parse_path(path)
112
+ URI.parse(path)
113
+ rescue URI::InvalidURIError => error
114
+ raise InvalidUrlPath.new(path, error)
115
+ end
116
+
117
+ VALID_OPTIONS = Set[:host, :port, :path, :protocol, :scheme].freeze
118
+
119
+ def validate_options(options)
120
+ keys = Set.new(options.keys)
121
+ unless keys.subset? VALID_OPTIONS
122
+ raise ArgumentError, "Passed invalid options: #{(keys - VALID_OPTIONS).to_a}, valid options are: #{VALID_OPTIONS.to_a}"
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,84 @@
1
+ module Roadie
2
+ # @api private
3
+ #
4
+ # Class that rewrites URLs in the DOM.
5
+ class UrlRewriter
6
+ # @param [UrlGenerator] generator
7
+ def initialize(generator)
8
+ @generator = generator
9
+ end
10
+
11
+ # Mutates the passed DOM tree, rewriting certain element's attributes.
12
+ #
13
+ # This will make all a[href] and img[src] into absolute URLs, as well as
14
+ # all "url()" directives inside style-attributes.
15
+ #
16
+ # [nil] is returned so no one can misunderstand that this method mutates
17
+ # the passed instance.
18
+ #
19
+ # @param [Nokogiri::HTML::Document] dom
20
+ # @return [nil] DOM tree is mutated
21
+ def transform_dom(dom)
22
+ # Use only a single loop to do this
23
+ dom.css("a[href], img[src], *[style]").each do |element|
24
+ transform_element_style element if element.has_attribute?('style')
25
+ transform_element element
26
+ end
27
+ nil
28
+ end
29
+
30
+ # Mutates passed CSS, rewriting url() directives.
31
+ #
32
+ # This will make all URLs inside url() absolute.
33
+ #
34
+ # [nil] is returned so no one can misunderstand that this method mutates
35
+ # the passed string.
36
+ #
37
+ # @param [String] css the css to mutate
38
+ # @return [nil] css is mutated
39
+ def transform_css(css)
40
+ css.gsub!(CSS_URL_REGEXP) do
41
+ matches = Regexp.last_match
42
+ "url(#{matches[:quote]}#{generate_url(matches[:url])}#{matches[:quote]})"
43
+ end
44
+ end
45
+
46
+ private
47
+ def generate_url(*args) @generator.generate_url(*args) end
48
+
49
+ # Regexp matching all the url() declarations in CSS
50
+ #
51
+ # It matches without any quotes and with both single and double quotes
52
+ # inside the parenthesis. There's much room for improvement, of course.
53
+ CSS_URL_REGEXP = %r{
54
+ url\(
55
+ (?<quote>
56
+ (?:["']|%22)? # Optional opening quote
57
+ )
58
+ (?<url> # The URL.
59
+ # We match URLs with parenthesis inside it here,
60
+ # so url(foo(bar)baz) will match correctly.
61
+ [^(]* # Text leading up to before opening parens
62
+ (?:\([^)]*\))* # Texts containing parens pairs
63
+ [^(]+ # Texts without parens - required
64
+ )
65
+ \k'quote' # Closing quote
66
+ \)
67
+ }x
68
+
69
+ def transform_element(element)
70
+ case element.name
71
+ when "a" then element["href"] = generate_url element["href"]
72
+ when "img" then element["src"] = generate_url element["src"]
73
+ end
74
+ end
75
+
76
+ def transform_element_style(element)
77
+ # We need to use a setter for Nokogiri to detect the string mutation.
78
+ # If nokogiri used "dumber" data structures, this would all be redundant.
79
+ css = element["style"]
80
+ transform_css css
81
+ element["style"] = css
82
+ end
83
+ end
84
+ end
@@ -1,3 +1,3 @@
1
1
  module Roadie
2
- VERSION = '2.4.3'
2
+ VERSION = '3.0.0.pre1'
3
3
  end
data/roadie.gemspec CHANGED
@@ -11,20 +11,16 @@ Gem::Specification.new do |s|
11
11
  s.authors = ['Magnus Bergmark']
12
12
  s.email = ['magnus.bergmark@gmail.com']
13
13
  s.homepage = 'http://github.com/Mange/roadie'
14
- s.summary = %q{Making HTML emails comfortable for the Rails rockstars}
15
- s.description = %q{Roadie tries to make sending HTML emails a little less painful in Rails 3 by inlining stylesheets and rewrite relative URLs for you.}
14
+ s.summary = %q{Making HTML emails comfortable for the Ruby rockstars}
15
+ s.description = %q{Roadie tries to make sending HTML emails a little less painful by inlining stylesheets and rewriting relative URLs for you.}
16
+ s.license = "MIT"
16
17
 
17
- s.add_dependency 'nokogiri', RUBY_VERSION < '1.9.3' ? ['> 1.5.0', '< 1.6.0'] : '> 1.5.0'
18
+ s.required_ruby_version = ">= 1.9"
19
+
20
+ s.add_dependency 'nokogiri', '~> 1.6.0'
18
21
  s.add_dependency 'css_parser', '~> 1.3.4'
19
- s.add_dependency 'actionmailer', '> 3.0.0', '< 5.0.0'
20
- s.add_dependency 'sprockets'
21
22
 
22
- s.add_development_dependency 'rake'
23
- s.add_development_dependency 'rails'
24
23
  s.add_development_dependency 'rspec'
25
- s.add_development_dependency 'rspec-rails'
26
-
27
- s.add_development_dependency 'appraisal'
28
24
 
29
25
  s.extra_rdoc_files = %w[README.md Changelog.md]
30
26
  s.require_paths = %w[lib]
@@ -0,0 +1 @@
1
+ em { font-size: 200%; }
@@ -0,0 +1 @@
1
+ body { color: green; }
@@ -1,110 +1,140 @@
1
1
  require 'spec_helper'
2
2
 
3
- module Roadie
4
- shared_examples "roadie integration" do
5
- mailer = Class.new(AnonymousMailer) do
6
- default :css => 'integration', :from => 'john@example.com'
7
- append_view_path FIXTURES_PATH.join('views')
8
-
9
- # Needed for correct path lookup
10
- self.mailer_name = "integration_mailer"
11
-
12
- def notification(to, reason)
13
- @reason = reason
14
- mail(:subject => 'Notification for you', :to => to) { |format| format.html; format.text }
15
- end
16
-
17
- def marketing(to)
18
- headers('X-Spam' => 'No way! Trust us!')
19
- mail(:subject => 'Buy cheap v1agra', :to => to)
20
- end
21
-
22
- def url_options
23
- # This allows apps to calculate any options on a per-email basis
24
- super.merge(:protocol => 'https')
25
- end
26
- end
27
-
28
- def parse_html_in_email(mail)
29
- Nokogiri::HTML.parse mail.html_part.body.decoded
30
- end
31
-
32
- before(:each) do
33
- change_default_url_options(:host => 'example.app.org')
34
- mailer.delivery_method = :test
35
- end
36
-
37
- it "inlines styles for an email" do
38
- email = mailer.notification('doe@example.com', 'your quota limit has been reached')
39
-
40
- email.to.should == ['doe@example.com']
41
- email.from.should == ['john@example.com']
42
- email.should have(2).parts
43
-
44
- email.text_part.body.decoded.should_not match(/<.*>/)
45
-
46
- html = email.html_part.body.decoded
47
- html.should include '<!DOCTYPE'
48
- html.should include '<head'
49
-
50
- document = parse_html_in_email(email)
51
- document.should have_selector('body #message h1')
52
- document.should have_styling('background' => 'url(https://example.app.org/images/dots.png) repeat-x').at_selector('body')
53
- document.should have_selector('strong[contains("quota")]')
54
-
55
- # If we deliver mails we can catch weird problems with headers being invalid
56
- email.deliver
57
- end
58
-
59
- it "does not add headers for the roadie options" do
60
- email = mailer.notification('doe@example.com', 'no berries left in chest')
61
- email.header.fields.map(&:name).should_not include('css')
62
- end
63
-
64
- it "keeps custom headers in place" do
65
- email = mailer.marketing('everyone@inter.net')
66
- email.header['X-Spam'].should be_present
67
- end
3
+ describe "Roadie functionality" do
4
+ def parse_html(html)
5
+ Nokogiri::HTML.parse(html)
6
+ end
68
7
 
69
- it "applies CSS3 styles" do
70
- email = mailer.notification('doe@example.com', 'your quota limit has been reached')
71
- document = parse_html_in_email(email)
72
- strong_node = document.css('strong').first
73
- stylings = SpecHelpers.styling_of_node(strong_node)
74
- stylings.should include(['box-shadow', '#62b0d7 1px 1px 1px 1px inset, #aaaaaa 1px 1px 3px 0'])
75
- stylings.should include(['-o-box-shadow', '#62b0d7 1px 1px 1px 1px inset, #aaaaaa 1px 1px 3px 0'])
8
+ it "adds missing structure" do
9
+ html = "<h1>Hello world!</h1>".encode("Shift_JIS")
10
+ document = Roadie::Document.new(html)
11
+ result = document.transform
12
+
13
+ unless defined?(JRuby)
14
+ # JRuby has a bug that makes DTD manipulation impossible
15
+ # See Nokogiri bugs #984 and #985
16
+ # https://github.com/sparklemotion/nokogiri/issues/984
17
+ # https://github.com/sparklemotion/nokogiri/issues/985
18
+ result.should include("<!DOCTYPE html>")
76
19
  end
77
20
 
78
- it "only removes the css option when disabled" do
79
- Rails.application.config.roadie.enabled = false
80
-
81
- email = mailer.notification('doe@example.com', 'your quota limit has been reached')
21
+ result.should include("<html>")
22
+ result.should include("<head>")
23
+ result.should include("<body>")
82
24
 
83
- email.header.fields.map(&:name).should_not include('css')
25
+ result.should include("<meta")
26
+ result.should include("text/html; charset=Shift_JIS")
27
+ end
84
28
 
85
- email.to.should == ['doe@example.com']
86
- email.from.should == ['john@example.com']
87
- email.should have(2).parts
29
+ it "inlines given css" do
30
+ document = Roadie::Document.new <<-HTML
31
+ <html>
32
+ <head>
33
+ <title>Hello world!</title>
34
+ </head>
35
+ <body>
36
+ <h1>Hello world!</h1>
37
+ <p>Check out these <em>awesome</em> prices!</p>
38
+ </body>
39
+ </html>
40
+ HTML
41
+ document.add_css <<-CSS
42
+ em { color: red; }
43
+ h1 { text-align: center; }
44
+ CSS
45
+
46
+ result = parse_html document.transform
47
+ result.should have_styling('text-align' => 'center').at_selector('h1')
48
+ result.should have_styling('color' => 'red').at_selector('p > em')
49
+ end
88
50
 
89
- html = email.html_part.body.decoded
90
- html.should_not include '<!DOCTYPE'
91
- html.should_not include '<head'
51
+ it "inlines css from disk" do
52
+ document = Roadie::Document.new <<-HTML
53
+ <!DOCTYPE html>
54
+ <html>
55
+ <head>
56
+ <title>Hello world!</title>
57
+ <link rel="stylesheet" href="/spec/fixtures/big_em.css">
58
+ </head>
59
+ <body>
60
+ <h1>Hello world!</h1>
61
+ <p>Check out these <em>awesome</em> prices!</p>
62
+ </body>
63
+ </html>
64
+ HTML
65
+
66
+ result = parse_html document.transform
67
+ result.should have_styling('font-size' => '200%').at_selector('p > em')
68
+ end
92
69
 
93
- document = parse_html_in_email(email)
94
- document.should_not have_styling('color' => '#eee').at_selector('h1')
95
- document.should_not have_styling('background' => 'url(https://example.app.org/images/dots.png) repeat-x').at_selector('body')
96
- end
70
+ it "crashes when stylesheets cannot be found, unless using NullProvider" do
71
+ document = Roadie::Document.new <<-HTML
72
+ <!DOCTYPE html>
73
+ <html>
74
+ <head>
75
+ <link rel="stylesheet" href="/spec/fixtures/does_not_exist.css">
76
+ </head>
77
+ <body>
78
+ </body>
79
+ </html>
80
+ HTML
81
+
82
+ expect { document.transform }.to raise_error(Roadie::CssNotFound, /does_not_exist\.css/)
83
+
84
+ document.asset_providers << Roadie::NullProvider.new
85
+ expect { document.transform }.to_not raise_error
97
86
  end
98
87
 
99
- describe "filesystem integration" do
100
- it_behaves_like "roadie integration" do
101
- before(:each) { Rails.application.config.assets.enabled = false }
102
- end
88
+ it "makes URLs absolute" do
89
+ document = Roadie::Document.new <<-HTML
90
+ <!DOCTYPE html>
91
+ <html>
92
+ <head>
93
+ <style>
94
+ body { background: url("/assets/bg-abcdef1234567890.png"); }
95
+ </style>
96
+ <link rel="stylesheet" href="/style.css">
97
+ </head>
98
+ <body>
99
+ <a href="/about_us"><img src="/assets/about_us-abcdef1234567890.png" alt="About us"></a>
100
+ </body>
101
+ </html>
102
+ HTML
103
+
104
+ document.asset_providers = TestProvider.new(
105
+ "/style.css" => "a { background: url(/assets/link-abcdef1234567890.png); }"
106
+ )
107
+ document.url_options = {host: "myapp.com", scheme: "https", path: "rails/app/"}
108
+ result = parse_html document.transform
109
+
110
+ result.at_css("a")["href"].should == "https://myapp.com/rails/app/about_us"
111
+
112
+ result.at_css("img")["src"].should == "https://myapp.com/rails/app/assets/about_us-abcdef1234567890.png"
113
+
114
+ result.should have_styling(
115
+ "background" => 'url("https://myapp.com/rails/app/assets/bg-abcdef1234567890.png")'
116
+ ).at_selector("body")
117
+
118
+ result.should have_styling(
119
+ "background" => 'url(https://myapp.com/rails/app/assets/link-abcdef1234567890.png)'
120
+ ).at_selector("a")
103
121
  end
104
122
 
105
- describe "asset pipeline integration" do
106
- it_behaves_like "roadie integration" do
107
- before(:each) { Rails.application.config.assets.enabled = true }
108
- end
123
+ it "allows custom callbacks during inlining" do
124
+ document = Roadie::Document.new <<-HTML
125
+ <!DOCTYPE html>
126
+ <html>
127
+ <body>
128
+ <span>Hello world</span>
129
+ </body>
130
+ </html>
131
+ HTML
132
+
133
+ document.before_transformation = proc { |dom| dom.at_css("body")["class"] = "roadie" }
134
+ document.after_transformation = proc { |dom| dom.at_css("span").remove }
135
+
136
+ result = parse_html document.transform
137
+ result.at_css("body")["class"].should == "roadie"
138
+ result.at_css("span").should be_nil
109
139
  end
110
140
  end