roadie 1.0.0.pre1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,108 @@
1
+ h1. Roadie
2
+
3
+ Roadie tries to make sending HTML emails a little less painful in Rails 3 by inlining stylesheets and rewrite relative URLs for you.
4
+
5
+ If you want to have this in Rails 2, please see "MailStyle":https://www.github.com/purify/mail_style.
6
+
7
+ h2. How does it work?
8
+
9
+ Email clients have bad support for stylesheets, and some of them blocks stylesheets from downloading. The easiest way to handle this is to work with all styles inline, but that is error prone and hard to work with as you cannot use classes and/or reuse styling.
10
+
11
+ This gem helps making this easier by automatically inlining stylesheet rules into the document before sending it. You just give it a list of stylesheets and it will go though all of the selectors assigning the styles to the maching elements. Careful attention has been put into rules being applied in the correct order, so it should behave just like in the browser[1].
12
+
13
+ Roadie also rewrites all relative URLs in the email to a absolute counterpart, making images you insert and those referenced in your stylesheets work. No more headaches about how to write the stylesheets while still having them work with emails from your acceptance environments.
14
+
15
+ fn1. Of course, rules like @:hover@ will not work by definition. Only static styles can be added.
16
+
17
+ h2. Features
18
+
19
+ * Writes CSS styles inline
20
+ ** Respects @!important@ styles
21
+ ** Does not overwrite styles already present in the @style@ attribute of tags
22
+ ** Supports the same CSS selectors as "Nokogiri":http://nokogiri.org/ (use CSS3 selectors in your emails!)
23
+ * Makes image urls absolute
24
+ ** Hostname and port configurable on a per-environment basis
25
+ * Makes link <code>href</code>s absolute
26
+ * Automatically adds proper html skeleton when missing (you don't have to create a layout for emails)[2]
27
+
28
+ fn2. This might be removed in a future version, though. You really ought to create a good layout and not let Roadie guess how you want to have it structured
29
+
30
+ h3. What about Sass / Less?
31
+
32
+ Sass is supported "by accident" as long as the stylesheets are generated and stored in the stylesheets directory. This is the default behavior from Sass. You are recommended to add a deploy task that generates the stylesheets to make sure that they are present at all times.
33
+
34
+ h2. Install
35
+
36
+ Add the gem to Rails' Gemfile
37
+ <pre><code>gem 'roadie'</code></pre>
38
+
39
+ h2. Usage
40
+
41
+ Simply specify the <code>:css</code> option to mailer:
42
+
43
+ <pre><code>class Notifier < ActionMailer::Base
44
+ default :css => :email, :from => 'support@mycompany.com'
45
+ def registration_mail
46
+ mail(:subject => 'Welcome Aboard', :to => 'someone@example.com')
47
+ end
48
+
49
+ def newsletter
50
+ mail(:subject => 'Newsletter', :to => 'someone@example.com', :css => [:email, :newsletter])
51
+ end
52
+ end</code></pre>
53
+ (you could also use the @defaults@ method in ActionMailer)
54
+
55
+ This will look for a css file called @email.css@ in your @public/stylesheets@ folder. The @css@ method can take either a string, a symbol or an array of both. You should pass the CSS filename without the ".css" extension.
56
+
57
+ h3. Image URL Correcting
58
+
59
+ If you have @default_url_options[:host]@ set in your mailer, then Roadie will do it's best to make the urls of images and in stylesheets absolute.
60
+
61
+ In @application.rb@:
62
+ <pre><code>class Application
63
+ config.action_mailer.default_url_options = {:host => 'example.com'}
64
+ end</code></pre>
65
+
66
+ If you want to to be different depending on your environment, just set it in your environment's configuration instead.
67
+
68
+ h3. Ignoring stylesheets
69
+
70
+ By default, @style@ elements in the email document's @head@ are processed along with the stylesheets and removed from the @head@.
71
+
72
+ You can set a special <code>data-immutable="true"</code> attribute on @style@ tags you do not want to be processed and removed from the document's @head@. This is the place to put things like @:hover@ selectors that you want to have for email clients allowing them.
73
+
74
+ Style elements with <code>media="print"</code> are always ignored.
75
+
76
+ h2. Bugs / TODO
77
+
78
+ * Improve overall performance
79
+ * Clean up stylesheet assignment code
80
+
81
+ h2. History and contributors
82
+
83
+ This gem was originally developed for Rails 2 use on "Purify":http://purifyapp.com under the name "MailStyle":https://www.github.com/purify/mail_style. However, the author stopped maintaining it and a fork took place to make it Rails 3 compatible.
84
+
85
+ The following people have contributed to the orignal gem:
86
+
87
+ * "Jim Neath":http://jimneath.org (Original author)
88
+ * "Lars Klevans":http://tastybyte.blogspot.com/
89
+ * "Jonas Grimfelt":http://github.com/grimen
90
+ * "Ben Johnson":http://www.binarylogic.com
91
+ * "Istvan Hoka":http://istvanhoka.com/
92
+ * "Voraz":http://blog.voraz.com.br
93
+
94
+ h2. License
95
+
96
+ (The MIT License)
97
+
98
+ Copyright &copy; 2009-2011
99
+
100
+ * "Jim Neath":http://jimneath.org
101
+ * Magnus Bergmark <magnus.bergmark@gmail.com>
102
+
103
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the ‘Software’), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
104
+
105
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
106
+
107
+ THE SOFTWARE IS PROVIDED ‘AS IS’, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
108
+
@@ -0,0 +1,60 @@
1
+ # encoding: utf-8
2
+ require 'rubygems'
3
+ require 'bundler'
4
+
5
+ begin
6
+ Bundler.setup(:default, :development)
7
+ rescue Bundler::BundlerError => e
8
+ $stderr.puts e.message
9
+ $stderr.puts "Run `bundle install` to install missing gems"
10
+ exit e.status_code
11
+ end
12
+
13
+ require 'rake'
14
+ require 'jeweler'
15
+
16
+ require 'rake/rdoctask'
17
+ require 'rspec/core/rake_task'
18
+
19
+ NAME = "roadie"
20
+ EXTRA_RDOC_FILES = %w[README.textile]
21
+ Jeweler::Tasks.new do |t|
22
+ t.name = NAME
23
+ t.summary = %{Making HTML emails comfortable for the Rails rockstars}
24
+ t.email = "magnus.bergmark@gmail.com"
25
+ t.homepage = "http://github.com/Mange/roadie"
26
+ t.description = %{Roadie tries to make sending HTML emails a little less painful in Rails 3 by inlining stylesheets and rewrite relative URLs for you.}
27
+ t.author = "Magnus Bergmark"
28
+
29
+ t.require_path = 'lib'
30
+ t.files = %w(Rakefile) + EXTRA_RDOC_FILES + Dir.glob(File.join(*%w[{lib,spec} ** *]).to_s)
31
+ t.extra_rdoc_files = EXTRA_RDOC_FILES
32
+
33
+ # dependencies defined in Gemfile
34
+ end
35
+
36
+ Jeweler::RubygemsDotOrgTasks.new
37
+
38
+ desc "Generate documentation for the #{NAME} plugin."
39
+ Rake::RDocTask.new(:rdoc) do |t|
40
+ t.rdoc_dir = 'rdoc'
41
+ t.title = NAME
42
+ t.options << '--line-numbers' << '--inline-source'
43
+ t.rdoc_files.include(EXTRA_RDOC_FILES)
44
+ t.rdoc_files.include('lib/**/*.rb')
45
+ end
46
+
47
+ desc "Run plugin specs for #{NAME}."
48
+ RSpec::Core::RakeTask.new('spec') do |t|
49
+ t.pattern = 'spec/**/*_spec.rb'
50
+ t.rspec_opts = ["-c"]
51
+ end
52
+
53
+ desc "Run plugin specs for #{NAME} with specdoc formatting and colors"
54
+ RSpec::Core::RakeTask.new('specdoc') do |t|
55
+ t.pattern = 'spec/**/*_spec.rb'
56
+ t.rspec_opts = ["--format specdoc", "-c"]
57
+ end
58
+
59
+ desc "Default: Run specs."
60
+ task :default => :spec
@@ -0,0 +1,28 @@
1
+ module Roadie
2
+ class CSSFileNotFound < StandardError; end
3
+
4
+ def self.inline_css(*args);
5
+ Roadie::Inliner.new(*args).execute
6
+ end
7
+
8
+ def self.load_css(root, targets)
9
+ loaded_css = []
10
+ stylesheets = root.join('public', 'stylesheets')
11
+
12
+ targets.map { |target| stylesheets.join("#{target}.css") }.each do |target_file|
13
+ if target_file.exist?
14
+ loaded_css << target_file.read
15
+ else
16
+ raise CSSFileNotFound, "Could not find #{target_file}"
17
+ end
18
+ end
19
+ loaded_css.join("\n")
20
+ end
21
+ end
22
+
23
+ require 'roadie/inliner'
24
+
25
+ require 'action_mailer'
26
+ require 'roadie/action_mailer_extensions'
27
+
28
+ ActionMailer::Base.send :include, Roadie::ActionMailerExtensions
@@ -0,0 +1,49 @@
1
+ require 'uri'
2
+ require 'nokogiri'
3
+ require 'css_parser'
4
+
5
+ module Roadie
6
+ module ActionMailerExtensions
7
+ def self.included(base)
8
+ base.class_eval do
9
+ alias_method_chain :collect_responses_and_parts_order, :inline_styles
10
+ alias_method_chain :mail, :inline_styles
11
+ end
12
+ end
13
+
14
+ protected
15
+ def mail_with_inline_styles(headers = {}, &block)
16
+ @inline_style_css_targets = headers[:css]
17
+ mail_without_inline_styles(headers.except(:css), &block).tap do |email|
18
+ email[:css] = nil
19
+ end
20
+ end
21
+
22
+ def collect_responses_and_parts_order_with_inline_styles(headers, &block)
23
+ responses, order = collect_responses_and_parts_order_without_inline_styles(headers, &block)
24
+ new_responses = []
25
+ responses.each do |response|
26
+ new_responses << inline_style_response(response)
27
+ end
28
+ [new_responses, order]
29
+ end
30
+
31
+ private
32
+ def inline_style_response(response)
33
+ if response[:content_type] == 'text/html'
34
+ response.merge :body => Roadie.inline_css(css_rules, response[:body], Rails.application.config.action_mailer.default_url_options)
35
+ else
36
+ response
37
+ end
38
+ end
39
+
40
+ def css_targets
41
+ return nil if @inline_style_css_targets == false
42
+ Array(@inline_style_css_targets || self.class.default[:css] || []).map { |target| target.to_s }
43
+ end
44
+
45
+ def css_rules
46
+ @css_rules ||= Roadie.load_css(Rails.root, css_targets) if css_targets.present?
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,145 @@
1
+ module Roadie
2
+ class Inliner
3
+ CSS_URL_REGEXP = %r{
4
+ url\(
5
+ (["']?)
6
+ (
7
+ [^(]* # Text leading up to before opening parens
8
+ (?:\([^)]*\))* # Texts containing parens pairs
9
+ [^(]+ # Texts without parens - required
10
+ )
11
+ \1
12
+ \)
13
+ }x
14
+
15
+ attr_reader :css, :html, :url_options
16
+
17
+ def initialize(css, html, url_options)
18
+ @css = css
19
+ @inline_css = []
20
+ @html = html
21
+ @url_options = url_options
22
+ end
23
+
24
+ def execute
25
+ adjust_html do |document|
26
+ add_missing_structure(document)
27
+ extract_inline_style_elements(document)
28
+ inline_css_rules(document)
29
+ make_image_urls_absolute(document)
30
+ make_style_urls_absolute(document)
31
+ end
32
+ end
33
+
34
+ private
35
+ def inline_css
36
+ @inline_css.join("\n")
37
+ end
38
+
39
+ def parsed_css
40
+ CssParser::Parser.new.tap do |parser|
41
+ parser.add_block!(css) if css
42
+ parser.add_block!(inline_css)
43
+ end
44
+ end
45
+
46
+ def adjust_html
47
+ Nokogiri::HTML.parse(html).tap do |document|
48
+ yield document
49
+ end.to_html
50
+ end
51
+
52
+ def add_missing_structure(document)
53
+ html_node = document.at_css('html')
54
+ html_node['xmlns'] ||= 'http://www.w3.org/1999/xhtml'
55
+
56
+ if document.at_css('html > head').present?
57
+ head = document.at_css('html > head')
58
+ else
59
+ head = Nokogiri::XML::Node.new('head', document)
60
+ document.at_css('html').children.before(head)
61
+ end
62
+
63
+ unless document.at_css('html > head > meta[http-equiv=Content-Type]')
64
+ meta = Nokogiri::XML::Node.new('meta', document)
65
+ meta['http-equiv'] = 'Content-Type'
66
+ meta['content'] = 'text/html; charset=utf-8'
67
+ head.add_child(meta)
68
+ end
69
+ end
70
+
71
+ def extract_inline_style_elements(document)
72
+ document.css("style").each do |style|
73
+ next if style['media'] == 'print' or style['data-immutable']
74
+ @inline_css << style.content
75
+ style.remove
76
+ end
77
+ end
78
+
79
+ def inline_css_rules(document)
80
+ matched_elements = {}
81
+ assign_rules_to_elements(document, matched_elements)
82
+
83
+ matched_elements.each do |element, rules|
84
+ rules_string = rules.map { |property, rule| [property, rule[:value]].join(':') }.join('; ')
85
+ element['style'] = [rules_string, element['style']].compact.join('; ')
86
+ end
87
+ end
88
+
89
+ def assign_rules_to_elements(document, matched_elements)
90
+ parsed_css.each_rule_set do |rules|
91
+ rules.selectors.reject { |selector| selector.include?(':') }.each do |selector|
92
+ document.css(selector.strip).each do |element|
93
+ register_rules_for_element(matched_elements, element, selector, rules)
94
+ end
95
+ end
96
+ end
97
+ end
98
+
99
+ def register_rules_for_element(store, element, selector, rules)
100
+ specificity = CssParser.calculate_specificity(selector)
101
+ element_rules = (store[element] ||= {})
102
+ rules.each_declaration do |property, value, important|
103
+ stored = (element_rules[property] ||= {:specificity => -1})
104
+ more_specific = (stored[:specificity] <= specificity)
105
+ if (important and not stored[:important]) or (important and stored[:important] and more_specific) or (more_specific and not stored[:important])
106
+ stored.merge!(:value => value, :specificity => specificity, :important => important)
107
+ end
108
+ end
109
+ end
110
+
111
+ def make_image_urls_absolute(document)
112
+ document.css('img').each do |img|
113
+ img['src'] = ensure_absolute_url(img['src']) if img['src']
114
+ end
115
+ end
116
+
117
+ def make_style_urls_absolute(document)
118
+ document.css('*[style]').each do |element|
119
+ styling = element['style']
120
+ element['style'] = styling.gsub(CSS_URL_REGEXP) { "url(#{$1}#{ensure_absolute_url($2, '/stylesheets')}#{$1})" }
121
+ end
122
+ end
123
+
124
+ def ensure_absolute_url(url, base_path = nil)
125
+ base, uri = absolute_url_base(base_path), URI.parse(url)
126
+ if uri.relative? and base
127
+ base.merge(uri).to_s
128
+ else
129
+ uri.to_s
130
+ end
131
+ rescue URI::InvalidURIError
132
+ return url
133
+ end
134
+
135
+ def absolute_url_base(base_path)
136
+ return nil unless url_options
137
+ URI::Generic.build({
138
+ :scheme => url_options[:protocol] || 'http',
139
+ :host => url_options[:host],
140
+ :port => url_options[:port],
141
+ :path => base_path
142
+ })
143
+ end
144
+ end
145
+ end
@@ -0,0 +1 @@
1
+ contents of bar
@@ -0,0 +1 @@
1
+ contents of foo
@@ -0,0 +1,4 @@
1
+ body { background: url(../images/dots.png) repeat-x; }
2
+ #message { background-color: #fff; margin: 0 auto; width: 75%; }
3
+
4
+ h1 { color: #eee; }
@@ -0,0 +1,8 @@
1
+ <div id="message">
2
+ <h1>Dear person</1>
3
+
4
+ <p>I have to inform you that <strong><%= @reason %></strong>.</p>
5
+
6
+ <p>Thank you,<br />
7
+ The app</p>
8
+ </div>
@@ -0,0 +1,6 @@
1
+ = Dear person =
2
+
3
+ I have to inform you that <%= @reason %>.
4
+
5
+ Thank you,
6
+ The app
@@ -0,0 +1,46 @@
1
+ require 'spec_helper'
2
+
3
+ describe "roadie integration" do
4
+ class TestApplication
5
+ def config
6
+ OpenStruct.new(:action_mailer => OpenStruct.new(:default_url_options => {:host => "example.app.org"}))
7
+ end
8
+ end
9
+
10
+ class IntegrationMailer < ActionMailer::Base
11
+ default :css => :integration, :from => 'john@example.com'
12
+ append_view_path Pathname.new(__FILE__).dirname.join('fixtures').join('views')
13
+
14
+ def notification(to, reason)
15
+ @reason = reason
16
+ mail(:subject => 'Notification for you', :to => to) { |format| format.html; format.text }
17
+ end
18
+ end
19
+
20
+ before(:each) do
21
+ Rails.stub!(:root => Pathname.new(__FILE__).dirname.join('fixtures'), :application => TestApplication.new)
22
+ IntegrationMailer.delivery_method = :test
23
+ end
24
+
25
+ it "should inline styles for an email" do
26
+ email = IntegrationMailer.notification('doe@example.com', 'your quota limit has been reached')
27
+
28
+ email.to.should == ['doe@example.com']
29
+ email.from.should == ['john@example.com']
30
+ email.should have(2).parts
31
+
32
+ email.parts.find { |part| part.mime_type == 'text/html' }.tap do |html_part|
33
+ document = Nokogiri::HTML.parse(html_part.body.decoded)
34
+ document.should have_selector('html > head + body')
35
+ document.should have_selector('body #message h1')
36
+ document.should have_styling('background' => 'url(http://example.app.org/images/dots.png) repeat-x').at_selector('body')
37
+ document.should have_selector('strong[contains("quota")]')
38
+ end
39
+
40
+ email.parts.find { |part| part.mime_type == 'text/plain' }.tap do |plain_part|
41
+ plain_part.body.decoded.should_not match(/<.*>/)
42
+ end
43
+
44
+ email.deliver
45
+ end
46
+ end
@@ -0,0 +1,112 @@
1
+ # coding: utf-8
2
+ require 'spec_helper'
3
+
4
+ describe Roadie::ActionMailerExtensions, "inlining styles" do
5
+ class InliningMailer < ActionMailer::Base
6
+ default :css => :simple
7
+
8
+ def multipart
9
+ mail(:subject => "Multipart email") do |format|
10
+ format.html { render :text => 'Hello HTML' }
11
+ format.text { render :text => 'Hello Text' }
12
+ end
13
+ end
14
+
15
+ # Not sure how to implement this one.
16
+ # TODO: Either remove or implement
17
+ def nested_multipart_mixed(css_file = nil)
18
+ raise "Nested multipart mixed is not implemented"
19
+ content_type "multipart/mixed"
20
+ part :content_type => "multipart/alternative", :content_disposition => "inline" do |p|
21
+ p.part :content_type => 'text/html', :body => 'Hello HTML'
22
+ p.part :content_type => 'text/plain', :body => 'Hello Text'
23
+ end
24
+ end
25
+
26
+ def singlepart_html
27
+ mail(:subject => "HTML email") do |format|
28
+ format.html { render :text => 'Hello HTML' }
29
+ end
30
+ end
31
+
32
+ def singlepart_plain
33
+ mail(:subject => "Text email") do |format|
34
+ format.text { render :text => 'Hello Text' }
35
+ end
36
+ end
37
+ end
38
+
39
+ before(:each) do
40
+ Roadie.stub!(:load_css => 'loaded css')
41
+ Roadie.stub!(:inline_css => 'unexpected value') # Make sure a implementation problem doesn't hurt these examples
42
+ end
43
+
44
+ describe "for singlepart text/plain" do
45
+ it "should not touch the email body" do
46
+ Roadie.should_not_receive(:inline_css)
47
+ InliningMailer.singlepart_plain
48
+ end
49
+ end
50
+
51
+ describe "for singlepart text/html" do
52
+ it "should inline css to the email body" do
53
+ Roadie.should_receive(:inline_css).with(anything, 'Hello HTML', anything).and_return('html')
54
+ InliningMailer.singlepart_html.body.decoded.should == 'html'
55
+ end
56
+ end
57
+
58
+ describe "for multipart" do
59
+ it "should keep both parts" do
60
+ InliningMailer.multipart.should have(2).parts
61
+ end
62
+
63
+ it "should inline css to the email's html part" do
64
+ Roadie.should_receive(:inline_css).with(anything, 'Hello HTML', anything).and_return('html')
65
+ email = InliningMailer.multipart
66
+ email.parts.find { |part| part.mime_type == 'text/html' }.body.decoded.should == 'html'
67
+ email.parts.find { |part| part.mime_type == 'text/plain' }.body.decoded.should == 'Hello Text'
68
+ end
69
+ end
70
+ end
71
+
72
+ describe Roadie::ActionMailerExtensions, "loading css files" do
73
+ class CssLoadingMailer < ActionMailer::Base
74
+ default :css => :default_value
75
+ def use_default
76
+ mail &with_empty_html_response
77
+ end
78
+
79
+ def override(target)
80
+ mail :css => target, &with_empty_html_response
81
+ end
82
+
83
+ protected
84
+ def with_empty_html_response
85
+ Proc.new { |format| format.html { render :text => '' } }
86
+ end
87
+ end
88
+
89
+ before(:each) do
90
+ Roadie.stub!(:inline_css => 'html')
91
+ end
92
+
93
+ it "should load the css specified in the default mailer settings" do
94
+ Roadie.should_receive(:load_css).with(Rails.root, ['default_value']).and_return('')
95
+ CssLoadingMailer.use_default
96
+ end
97
+
98
+ it "should load the css specified in the specific mailer action instead of the default choice" do
99
+ Roadie.should_receive(:load_css).with(Rails.root, ['specific']).and_return('')
100
+ CssLoadingMailer.override(:specific)
101
+ end
102
+
103
+ it "should load no css when specifying false in the mailer action" do
104
+ Roadie.should_not_receive(:load_css)
105
+ CssLoadingMailer.override(false)
106
+ end
107
+
108
+ it "should load multiple css files when given an array" do
109
+ Roadie.should_receive(:load_css).with(Rails.root, ['specific', 'other']).and_return('')
110
+ CssLoadingMailer.override([:specific, :other])
111
+ end
112
+ end
@@ -0,0 +1,221 @@
1
+ require 'spec_helper'
2
+
3
+ describe Roadie::Inliner do
4
+ def use_css(css); @css = css; end
5
+ def rendering(html, options = {})
6
+ Nokogiri::HTML.parse Roadie::Inliner.new(@css, html, options.fetch(:url_options, {:host => 'example.com'})).execute
7
+ end
8
+
9
+ describe "inlining styles" do
10
+ before(:each) do
11
+ # Make sure to have some css even when we don't specify any
12
+ # We have specific tests for when this is nil
13
+ use_css ''
14
+ end
15
+
16
+ it "should inline simple attributes" do
17
+ use_css 'p { color: green }'
18
+ rendering('<p></p>').should have_styling('color' => 'green').at_selector('p')
19
+ end
20
+
21
+ it "should combine multiple selectors into one" do
22
+ use_css "p { color: green; }
23
+ .tip { float: right; }"
24
+ rendering('<p class="tip"></p>').should have_styling('color' => 'green', 'float' => 'right').at_selector('p')
25
+ end
26
+
27
+ it "should use the ones attributes with the highest specificality when conflicts arises" do
28
+ use_css "p { color: red; }
29
+ .safe { color: green; border: 1px solid black; }"
30
+ rendering('<p class="safe"></p>').should have_styling('color' => 'green', 'border' => '1px solid black').at_selector('p')
31
+ end
32
+
33
+ it "should support multiple selectors for the same rules" do
34
+ use_css 'p, a { color: green; }'
35
+ rendering('<p></p><a></a>').tap do |document|
36
+ document.should have_styling('color' => 'green').at_selector('p')
37
+ document.should have_styling('color' => 'green').at_selector('a')
38
+ end
39
+ end
40
+
41
+ it "should respect !important properties" do
42
+ use_css "a { text-decoration: underline !important; }
43
+ a.hard-to-spot { text-decoration: none; }"
44
+ rendering('<a class="hard-to-spot"></a>').should have_styling('text-decoration' => 'underline').at_selector('a')
45
+ end
46
+
47
+ it "should combine with already present inline styles" do
48
+ use_css "p { color: green }"
49
+ rendering('<p style="font-size: 1.1em"></p>').should have_styling('color' => 'green', 'font-size' => '1.1em').at_selector('p')
50
+ end
51
+
52
+ it "should not overwrite already present inline styles" do
53
+ use_css "p { color: red }"
54
+ rendering('<p style="color: green"></p>').should have_styling('color' => 'green').at_selector('p')
55
+ end
56
+
57
+ it "should ignore selectors with :psuedo-classes" do
58
+ use_css 'p:hover { color: red }'
59
+ rendering('<p></p>').should_not have_styling('color' => 'red').at_selector('p')
60
+ end
61
+
62
+ describe "inline <style> elements" do
63
+ it "should be used for inlined styles" do
64
+ rendering(<<-HTML).should have_styling('color' => 'green', 'font-size' => '1.1em').at_selector('p')
65
+ <html>
66
+ <head>
67
+ <style type="text/css">p { color: green; }</style>
68
+ </head>
69
+ <body>
70
+ <p>Hello World</p>
71
+ <style type="text/css">p { font-size: 1.1em; }</style>
72
+ </body>
73
+ </html>
74
+ HTML
75
+ end
76
+
77
+ it "should be removed" do
78
+ rendering(<<-HTML).should_not have_selector('style')
79
+ <html>
80
+ <head>
81
+ <style type="text/css">p { color: green; }</style>
82
+ </head>
83
+ <body>
84
+ <style type="text/css">p { font-size: 1.1em; }</style>
85
+ </body>
86
+ </html>
87
+ HTML
88
+ end
89
+
90
+ it "should not be touched when data-immutable=true" do
91
+ document = rendering <<-HTML
92
+ <style type="text/css" data-immutable="true">p { color: red; }</style>
93
+ <p></p>
94
+ HTML
95
+ document.should have_selector('style[data-immutable=true]')
96
+ document.should_not have_styling('color' => 'red').at_selector('p')
97
+ end
98
+
99
+ it "should not be touched when media=print" do
100
+ document = rendering <<-HTML
101
+ <style type="text/css" media="print">p { color: red; }</style>
102
+ <p></p>
103
+ HTML
104
+ document.should have_selector('style[media=print]')
105
+ document.should_not have_styling('color' => 'red').at_selector('p')
106
+ end
107
+
108
+ it "should still be inlined when no external css rules are defined" do
109
+ use_css nil
110
+ rendering(<<-HTML).should have_styling('color' => 'green').at_selector('p')
111
+ <style type="text/css">p { color: green; }</style>
112
+ <p>Hello World</p>
113
+ HTML
114
+ end
115
+ end
116
+ end
117
+
118
+ describe "making urls absolute" do
119
+ it "should work on image sources" do
120
+ rendering('<img src="/images/foo.jpg" />').should have_attribute('src' => 'http://example.com/images/foo.jpg').at_selector('img')
121
+ rendering('<img src="../images/foo.jpg" />').should have_attribute('src' => 'http://example.com/images/foo.jpg').at_selector('img')
122
+ rendering('<img src="foo.jpg" />').should have_attribute('src' => 'http://example.com/foo.jpg').at_selector('img')
123
+ end
124
+
125
+ it "should not touch image sources that are already absolute" do
126
+ rendering('<img src="http://other.example.org/images/foo.jpg" />').should have_attribute('src' => 'http://other.example.org/images/foo.jpg').at_selector('img')
127
+ end
128
+
129
+ it "should work on inlined style attributes" do
130
+ rendering('<p style="background: url(/paper.png)"></p>').should have_styling('background' => 'url(http://example.com/paper.png)').at_selector('p')
131
+ rendering('<p style="background: url(&quot;/paper.png&quot;)"></p>').should have_styling('background' => 'url("http://example.com/paper.png")').at_selector('p')
132
+ end
133
+
134
+ it "should work on external style declarations" do
135
+ use_css "p { background-image: url(/paper.png); }
136
+ table { background-image: url('/paper.png'); }
137
+ div { background-image: url(\"/paper.png\"); }"
138
+ rendering('<p></p>').should have_styling('background-image' => 'url(http://example.com/paper.png)').at_selector('p')
139
+ rendering('<table></table>').should have_styling('background-image' => "url('http://example.com/paper.png')").at_selector('table')
140
+ rendering('<div></div>').should have_styling('background-image' => 'url("http://example.com/paper.png")').at_selector('div')
141
+ end
142
+
143
+ it "should not touch style urls that are already absolute" do
144
+ external_url = 'url(http://other.example.org/paper.png)'
145
+ use_css "p { background-image: #{external_url}; }"
146
+ rendering('<p></p>').should have_styling('background-image' => external_url).at_selector('p')
147
+ rendering(%(<div style="background-image: #{external_url}"></div>)).should have_styling('background-image' => external_url).at_selector('div')
148
+ end
149
+
150
+ it "should not touch the urls when no url options are defined" do
151
+ use_css "img { background: url(/a.jpg); }"
152
+ rendering('<img src="/b.jpg" />', :url_options => nil).tap do |document|
153
+ document.should have_attribute('src' => '/b.jpg').at_selector('img')
154
+ document.should have_styling('background' => 'url(/a.jpg)').at_selector('img')
155
+ end
156
+ end
157
+
158
+ it "should support port and protocol settings" do
159
+ use_css "img { background: url(/a.jpg); }"
160
+ rendering('<img src="/b.jpg" />', :url_options => {:host => 'example.com', :protocol => 'https', :port => '8080'}).tap do |document|
161
+ document.should have_attribute('src' => 'https://example.com:8080/b.jpg').at_selector('img')
162
+ document.should have_styling('background' => 'url(https://example.com:8080/a.jpg)').at_selector('img')
163
+ end
164
+ end
165
+
166
+ it "should not touch data: URIs" do
167
+ use_css "div { background: url(data:abcdef); }"
168
+ rendering('<div></div>').should have_styling('background' => 'url(data:abcdef)').at_selector('div')
169
+ end
170
+ end
171
+
172
+ describe "inserting tags" do
173
+ it "should insert a doctype if not present" do
174
+ rendering('<html><body></body></html>').to_xml.should include('<!DOCTYPE ')
175
+ rendering('<!DOCTYPE html><html><body></body></html>').to_xml.should_not match(/(DOCTYPE.*?){2}/)
176
+ end
177
+
178
+ it "should set xmlns of <html> to that of XHTML" do
179
+ rendering('<html><body></body></html>').should have_selector('html[xmlns="http://www.w3.org/1999/xhtml"]')
180
+ end
181
+
182
+ it "should insert basic html structure if not present" do
183
+ rendering('<h1>Hey!</h1>').should have_selector('html > head + body > h1')
184
+ end
185
+
186
+ it "should insert <head> if not present" do
187
+ rendering('<html><body></body></html>').should have_selector('html > head + body')
188
+ end
189
+
190
+ it "should insert meta tag describing content-type" do
191
+ rendering('<html><head></head><body></body></html>').should have_selector('head meta[http-equiv="Content-Type"][content="text/html; charset=utf-8"]')
192
+ end
193
+
194
+ it "should not insert duplicate meta tags describing content-type" do
195
+ rendering(<<-HTML).to_html.scan('meta').should have(1).item
196
+ <html>
197
+ <head>
198
+ <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1" />
199
+ </head>
200
+ </html>
201
+ HTML
202
+ end
203
+ end
204
+
205
+ describe "css url regex" do
206
+ it "should parse css urls" do
207
+ {
208
+ 'url(/foo.jpg)' => '/foo.jpg',
209
+ 'url("/foo.jpg")' => '/foo.jpg',
210
+ "url('/foo.jpg')" => '/foo.jpg',
211
+ 'url(http://localhost/foo.jpg)' => 'http://localhost/foo.jpg',
212
+ 'url("http://localhost/foo.jpg")' => 'http://localhost/foo.jpg',
213
+ "url('http://localhost/foo.jpg')" => 'http://localhost/foo.jpg',
214
+ 'url(/andromeda_(galaxy).jpg)' => '/andromeda_(galaxy).jpg',
215
+ }.each do |raw, expected|
216
+ raw =~ Roadie::Inliner::CSS_URL_REGEXP
217
+ $2.should == expected
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,27 @@
1
+ require 'spec_helper'
2
+
3
+ describe Roadie do
4
+ describe ".inline_css" do
5
+ it "should create an instance of Roadie::Inliner and execute it" do
6
+ Roadie::Inliner.should_receive(:new).with('attri', 'butes').and_return(double('inliner', :execute => 'html'))
7
+ Roadie.inline_css('attri', 'butes').should == 'html'
8
+ end
9
+ end
10
+
11
+ describe ".load_css(root, targets)" do
12
+ let(:fixtures_root) { Pathname.new(__FILE__).dirname.join('..', 'fixtures') }
13
+
14
+ it "should load files matching the target names under root/public/stylesheets" do
15
+ Roadie.load_css(fixtures_root, ['foo']).should == 'contents of foo'
16
+ end
17
+
18
+ it "should load files in order and join them with a newline" do
19
+ Roadie.load_css(fixtures_root, %w[foo bar]).should == "contents of foo\ncontents of bar"
20
+ Roadie.load_css(fixtures_root, %w[bar foo]).should == "contents of bar\ncontents of foo"
21
+ end
22
+
23
+ it "should raise a Roadie::CSSFileNotFound error when a css file could not be found" do
24
+ expect { Roadie.load_css(fixtures_root, ['not_here']) }.to raise_error(Roadie::CSSFileNotFound, /not_here/)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,101 @@
1
+ $: << File.dirname(__FILE__) + '/../lib'
2
+
3
+ require 'ostruct'
4
+ require 'rubygems'
5
+ require 'bundler'
6
+
7
+ begin
8
+ Bundler.setup(:default, :development)
9
+ rescue Bundler::BundlerError => e
10
+ $stderr.puts e.message
11
+ $stderr.puts "Run `bundle install` to install missing gems"
12
+ exit e.status_code
13
+ end
14
+
15
+ require 'rspec'
16
+ require 'action_mailer'
17
+ require 'roadie'
18
+
19
+ class TestApplication
20
+ def config
21
+ OpenStruct.new(:action_mailer => OpenStruct.new(:default_url_options => {:host => "example.com"}))
22
+ end
23
+ end
24
+
25
+ if defined?(Rails)
26
+ Rails.stub!(:root => Pathname.new('/path/to'), :application => TestApplication.new)
27
+ else
28
+ class Rails
29
+ def self.root; Pathname.new('/path/to'); end
30
+ def self.application; TestApplication.new; end
31
+ end
32
+ end
33
+
34
+ RSpec::Matchers.define :have_styling do |rules|
35
+ chain :at_selector do |selector|
36
+ @selector = selector
37
+ end
38
+
39
+ match do |document|
40
+ styles = parsed_styles(document)
41
+ if rules.nil?
42
+ styles.blank?
43
+ else
44
+ rules.stringify_keys.should == parsed_styles(document)
45
+ end
46
+ end
47
+
48
+ describe { "have styles #{rules.inspect} at selector #{@selector.inspect}" }
49
+ failure_message_for_should { |document| "expected styles at #{@selector.inspect} to be #{rules.inspect} but was #{parsed_styles(document).inspect}" }
50
+ failure_message_for_should_not { "expected styles at #{@selector.inspect} to not be #{rules.inspect}" }
51
+
52
+ def element_styles(document)
53
+ node = document.css(@selector).first
54
+ node && node['style']
55
+ end
56
+
57
+ def parsed_styles(document)
58
+ return @parsed_styles if defined?(@parsed_styles)
59
+ if (styles = element_styles(document)).present?
60
+ @parsed_styles = styles.split(';').inject({}) do |styles, item|
61
+ attribute, value = item.split(':', 2)
62
+ styles.merge!(attribute.strip => value.strip)
63
+ end
64
+ else
65
+ @parsed_styles = nil
66
+ end
67
+ end
68
+ end
69
+
70
+ RSpec::Matchers.define :have_attribute do |attribute|
71
+ chain :at_selector do |selector|
72
+ @selector = selector
73
+ end
74
+
75
+ match do |document|
76
+ name, expected = attribute.first
77
+ expected == attribute(document, name)
78
+ end
79
+
80
+ describe { "have attribute #{attribute.inspect} at selector #{@selector.inspect}" }
81
+ failure_message_for_should do |document|
82
+ name, expected = attribute.first
83
+ "expected #{name} attribute at #{@selector.inspect} to be #{expected.inspect} but was #{attribute(document, name).inspect}"
84
+ end
85
+ failure_message_for_should_not do |document|
86
+ name, expected = attribute.first
87
+ "expected #{name} attribute at #{@selector.inspect} to not be #{expected.inspect}"
88
+ end
89
+
90
+ def attribute(document, attribute_name)
91
+ node = document.css(@selector).first
92
+ node && node[attribute_name]
93
+ end
94
+ end
95
+
96
+ RSpec::Matchers.define :have_selector do |selector|
97
+ match { |document| document.css(selector).present? }
98
+ failure_message_for_should { "expected document to #{name_to_sentence}#{expected_to_sentence}"}
99
+ failure_message_for_should_not { "expected document to not #{name_to_sentence}#{expected_to_sentence}"}
100
+ end
101
+
metadata ADDED
@@ -0,0 +1,178 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: roadie
3
+ version: !ruby/object:Gem::Version
4
+ hash: 270495472
5
+ prerelease: true
6
+ segments:
7
+ - 1
8
+ - 0
9
+ - 0
10
+ - pre1
11
+ version: 1.0.0.pre1
12
+ platform: ruby
13
+ authors:
14
+ - Magnus Bergmark
15
+ autorequire:
16
+ bindir: bin
17
+ cert_chain: []
18
+
19
+ date: 2011-01-06 00:00:00 +01:00
20
+ default_executable:
21
+ dependencies:
22
+ - !ruby/object:Gem::Dependency
23
+ prerelease: false
24
+ version_requirements: &id001 !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ hash: 3
30
+ segments:
31
+ - 0
32
+ version: "0"
33
+ name: nokogiri
34
+ requirement: *id001
35
+ type: :runtime
36
+ - !ruby/object:Gem::Dependency
37
+ prerelease: false
38
+ version_requirements: &id002 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ hash: 3
44
+ segments:
45
+ - 0
46
+ version: "0"
47
+ name: css_parser
48
+ requirement: *id002
49
+ type: :runtime
50
+ - !ruby/object:Gem::Dependency
51
+ prerelease: false
52
+ version_requirements: &id003 !ruby/object:Gem::Requirement
53
+ none: false
54
+ requirements:
55
+ - - ~>
56
+ - !ruby/object:Gem::Version
57
+ hash: 7
58
+ segments:
59
+ - 3
60
+ - 0
61
+ - 0
62
+ version: 3.0.0
63
+ name: actionmailer
64
+ requirement: *id003
65
+ type: :runtime
66
+ - !ruby/object:Gem::Dependency
67
+ prerelease: false
68
+ version_requirements: &id004 !ruby/object:Gem::Requirement
69
+ none: false
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ hash: 3
74
+ segments:
75
+ - 0
76
+ version: "0"
77
+ name: rake
78
+ requirement: *id004
79
+ type: :development
80
+ - !ruby/object:Gem::Dependency
81
+ prerelease: false
82
+ version_requirements: &id005 !ruby/object:Gem::Requirement
83
+ none: false
84
+ requirements:
85
+ - - ~>
86
+ - !ruby/object:Gem::Version
87
+ hash: 270495428
88
+ segments:
89
+ - 1
90
+ - 5
91
+ - 0
92
+ - pre5
93
+ version: 1.5.0.pre5
94
+ name: jeweler
95
+ requirement: *id005
96
+ type: :development
97
+ - !ruby/object:Gem::Dependency
98
+ prerelease: false
99
+ version_requirements: &id006 !ruby/object:Gem::Requirement
100
+ none: false
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ hash: 15
105
+ segments:
106
+ - 2
107
+ - 0
108
+ - 0
109
+ version: 2.0.0
110
+ name: rspec-rails
111
+ requirement: *id006
112
+ type: :development
113
+ description: Roadie tries to make sending HTML emails a little less painful in Rails 3 by inlining stylesheets and rewrite relative URLs for you.
114
+ email: magnus.bergmark@gmail.com
115
+ executables: []
116
+
117
+ extensions: []
118
+
119
+ extra_rdoc_files:
120
+ - README.textile
121
+ files:
122
+ - README.textile
123
+ - Rakefile
124
+ - lib/roadie.rb
125
+ - lib/roadie/action_mailer_extensions.rb
126
+ - lib/roadie/inliner.rb
127
+ - spec/fixtures/public/stylesheets/bar.css
128
+ - spec/fixtures/public/stylesheets/foo.css
129
+ - spec/fixtures/public/stylesheets/integration.css
130
+ - spec/fixtures/views/integration_mailer/notification.html.erb
131
+ - spec/fixtures/views/integration_mailer/notification.text.erb
132
+ - spec/integration_spec.rb
133
+ - spec/lib/roadie/action_mailer_extensions_spec.rb
134
+ - spec/lib/roadie/inliner_spec.rb
135
+ - spec/lib/roadie_spec.rb
136
+ - spec/spec_helper.rb
137
+ has_rdoc: true
138
+ homepage: http://github.com/Mange/roadie
139
+ licenses: []
140
+
141
+ post_install_message:
142
+ rdoc_options: []
143
+
144
+ require_paths:
145
+ - lib
146
+ required_ruby_version: !ruby/object:Gem::Requirement
147
+ none: false
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ hash: 3
152
+ segments:
153
+ - 0
154
+ version: "0"
155
+ required_rubygems_version: !ruby/object:Gem::Requirement
156
+ none: false
157
+ requirements:
158
+ - - ">"
159
+ - !ruby/object:Gem::Version
160
+ hash: 25
161
+ segments:
162
+ - 1
163
+ - 3
164
+ - 1
165
+ version: 1.3.1
166
+ requirements: []
167
+
168
+ rubyforge_project:
169
+ rubygems_version: 1.3.7
170
+ signing_key:
171
+ specification_version: 3
172
+ summary: Making HTML emails comfortable for the Rails rockstars
173
+ test_files:
174
+ - spec/integration_spec.rb
175
+ - spec/lib/roadie/action_mailer_extensions_spec.rb
176
+ - spec/lib/roadie/inliner_spec.rb
177
+ - spec/lib/roadie_spec.rb
178
+ - spec/spec_helper.rb