mikedamage-premailer-plus 0.1.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.
- data/LICENSE +20 -0
- data/README.textile +41 -0
- data/Rakefile +56 -0
- data/VERSION.yml +4 -0
- data/bin/premailer_plus +108 -0
- data/lib/html_to_plain_text.rb +61 -0
- data/lib/premailer.rb +396 -0
- data/test/fixtures/client_support.html +10 -0
- data/test/fixtures/test-import.css +4 -0
- data/test/fixtures/test-with-folding.html +77 -0
- data/test/fixtures/test.css +90 -0
- data/test/fixtures/test.html +66 -0
- data/test/fixtures/test2.css +81 -0
- data/test/fixtures/test2.html +75 -0
- data/test/fixtures/test3-import.css +4 -0
- data/test/fixtures/test3-noimport.css +1 -0
- data/test/fixtures/test3-out.html +82 -0
- data/test/fixtures/test3.css +83 -0
- data/test/fixtures/test3.html +85 -0
- data/test/images/content_bg.jpg +0 -0
- data/test/images/inset.jpg +0 -0
- data/test/speed.rb +34 -0
- data/test/test_convert_to_plain_text.rb +30 -0
- data/test/test_helper.rb +6 -0
- data/test/test_link_resolver.rb +32 -0
- data/test/test_premailer.rb +22 -0
- data/test/test_premailer_download.rb +77 -0
- metadata +86 -0
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2009 Mike Green
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.textile
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
h1. premailer-plus
|
2
|
+
|
3
|
+
|*By:*||Mike Green ("mike.is.green@gmail.com":mailto:mike.is.green@gmail.com)|
|
4
|
+
|*Original Premailer By:*||Alex Dunae ("http://www.dunae.ca":http://www.dunae.ca)|
|
5
|
+
|
6
|
+
h2. Background
|
7
|
+
|
8
|
+
Premailer Plus is my set of additions and corrections to Alex Dunae's excellent "Premailer":http://code.dunae.ca/premailer.web/ application. Premailer is "Preflight for HTML email", i.e. it takes your HTML code, and makes it compatible with most email clients. Email clients aren't like web browsers; their support for CSS is spotty and erratic, and they don't always render your code the way you'd expect. Premailer takes your CSS and moves it inline to each matched HTML element, and it displays warnings if you use code that's not well supported by email clients. "Campaign Monitor":http://www.campaignmonitor.com sponsors and uses Premailer to get your code ready for mass emails. It's a one of a kind, indispensible service, but I've always had a few gripes about the Premailer Rubygem, so I finally decided to fork the code and apply my fixes and improvements.
|
9
|
+
|
10
|
+
h3. Problems with "Premailer Classic"
|
11
|
+
|
12
|
+
I downloaded the Premailer gem so that I could rock the excellence of Premailer on my Mac instead of having to rely on an external service. There's even a TextMate bundle available that lets you Premailer you code right in the editor. But when I downloaded it, I found one glaring problem: *When you run Premailer from the command line, it throws an error when you give it a local file instead of a URL.* Kind of defeats the purpose.
|
13
|
+
|
14
|
+
h3. Improvements in Premailer Plus
|
15
|
+
|
16
|
+
* I fixed the command line script so that it no longer throws errors when you feed it a local file
|
17
|
+
* New option to shorten the URLs of links in your plain text version
|
18
|
+
** "Bit.ly":http://bit.ly
|
19
|
+
* More to come!
|
20
|
+
|
21
|
+
h2. Installation
|
22
|
+
|
23
|
+
The easiest way to install Premailer Plus is to run the following code on your OS X or Linux terminal (should work on Windows too, but I'm not sure):
|
24
|
+
|
25
|
+
<pre>
|
26
|
+
$ sudo gem install mikedamage-premailer-plus
|
27
|
+
</pre>
|
28
|
+
|
29
|
+
You can also clone this repository and run @sudo rake install@ from the project root.
|
30
|
+
|
31
|
+
h2. Dependencies
|
32
|
+
|
33
|
+
* "RubyGems":http://rubygems.rubyforge.org
|
34
|
+
* "Hpricot":http://wiki.github.com/why/hpricot
|
35
|
+
* "css-parser":http://code.dunae.ca/css_parser/
|
36
|
+
* "text-reform":http://rubyforge.org/projects/text-format/
|
37
|
+
* "Bitly4r":http://wiki.cantremember.com/Bitly4R/HomePage
|
38
|
+
|
39
|
+
h2. Copyright
|
40
|
+
|
41
|
+
Copyright (C) 2009 Mike Green. See LICENSE for details.
|
data/Rakefile
ADDED
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'jeweler'
|
6
|
+
Jeweler::Tasks.new do |gem|
|
7
|
+
gem.name = "premailer-plus"
|
8
|
+
gem.summary = %Q{Miscellaneous improvements and fixes to Alex Dunae's Premailer gem}
|
9
|
+
gem.email = "mike.is.green@gmail.com"
|
10
|
+
gem.homepage = "http://github.com/mikedamage/premailer-plus"
|
11
|
+
gem.authors = ["Mike Green"]
|
12
|
+
|
13
|
+
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
|
14
|
+
end
|
15
|
+
rescue LoadError
|
16
|
+
puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
|
17
|
+
end
|
18
|
+
|
19
|
+
require 'rake/testtask'
|
20
|
+
Rake::TestTask.new(:test) do |test|
|
21
|
+
test.libs << 'lib' << 'test'
|
22
|
+
test.pattern = 'test/**/*_test.rb'
|
23
|
+
test.verbose = true
|
24
|
+
end
|
25
|
+
|
26
|
+
begin
|
27
|
+
require 'rcov/rcovtask'
|
28
|
+
Rcov::RcovTask.new do |test|
|
29
|
+
test.libs << 'test'
|
30
|
+
test.pattern = 'test/**/*_test.rb'
|
31
|
+
test.verbose = true
|
32
|
+
end
|
33
|
+
rescue LoadError
|
34
|
+
task :rcov do
|
35
|
+
abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
|
40
|
+
task :default => :test
|
41
|
+
|
42
|
+
require 'rake/rdoctask'
|
43
|
+
Rake::RDocTask.new do |rdoc|
|
44
|
+
if File.exist?('VERSION.yml')
|
45
|
+
config = YAML.load(File.read('VERSION.yml'))
|
46
|
+
version = "#{config[:major]}.#{config[:minor]}.#{config[:patch]}"
|
47
|
+
else
|
48
|
+
version = ""
|
49
|
+
end
|
50
|
+
|
51
|
+
rdoc.rdoc_dir = 'rdoc'
|
52
|
+
rdoc.title = "premailer-plus #{version}"
|
53
|
+
rdoc.rdoc_files.include('README*')
|
54
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
55
|
+
end
|
56
|
+
|
data/VERSION.yml
ADDED
data/bin/premailer_plus
ADDED
@@ -0,0 +1,108 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
#
|
3
|
+
# = Premailer Plus
|
4
|
+
#
|
5
|
+
# Miscellaneous improvements to Alex Dunae's Premailer gem
|
6
|
+
|
7
|
+
require "optparse"
|
8
|
+
require "optparse/time"
|
9
|
+
require "ostruct"
|
10
|
+
require "pathname"
|
11
|
+
require File.join(File.dirname(__FILE__), "../lib/premailer")
|
12
|
+
|
13
|
+
class PremailerApp
|
14
|
+
|
15
|
+
|
16
|
+
def initialize(args)
|
17
|
+
@args = args
|
18
|
+
@config = OpenStruct.new({
|
19
|
+
:plaintext => false,
|
20
|
+
:shorten_urls => false,
|
21
|
+
:base_url => '',
|
22
|
+
:infile => nil,
|
23
|
+
:outfile => '',
|
24
|
+
:querystring => '',
|
25
|
+
:warnings => false,
|
26
|
+
:verbose => true
|
27
|
+
})
|
28
|
+
end
|
29
|
+
|
30
|
+
def run
|
31
|
+
if parse_options and get_arguments
|
32
|
+
@pm_opts = {}
|
33
|
+
@pm_opts.merge!({:base_url => @config.base_url}) unless @config.base_url.empty?
|
34
|
+
@pm_opts.merge!({:link_query_string => @config.querystring}) unless @config.querystring.empty?
|
35
|
+
@pm_opts.merge!({:shorten_urls => true}) if @config.shorten_urls
|
36
|
+
@premailer = Premailer.new(@config.infile.to_s, @pm_opts)
|
37
|
+
|
38
|
+
write_premail_html
|
39
|
+
write_plain_text if @config.plaintext
|
40
|
+
show_warnings if @config.warnings
|
41
|
+
else
|
42
|
+
puts "Invalid options/arguments!"
|
43
|
+
exit 1
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
def parse_options
|
49
|
+
opts = OptionParser.new
|
50
|
+
opts.banner = DATA.read.gsub!(/FILE/, File.basename(__FILE__))
|
51
|
+
opts.on("-p", "--plaintext", "Output a plain-text version of the email in addition to HTML") { @config.plaintext = true }
|
52
|
+
opts.on("-s", "--shorten-urls", "Shorten URLs with Bit.ly in the plain-text version") { @config.shorten_urls = true }
|
53
|
+
opts.on("-b", "--baseurl URL", "Prepend this URL to links if your input is a local file.") {|url| @config.base_url = url }
|
54
|
+
opts.on("-q", "--querystring STRING", "Append this query string to link URLs") {|string| @config.querystring = string }
|
55
|
+
opts.on("-v", "--verbose", "Display status information during the pre-mailing process") { @config.verbose = true }
|
56
|
+
opts.on("-w", "--warnings", "Display CSS support warnings") { @config.warnings = true }
|
57
|
+
opts.on("-h", "--help", "Show this information") { puts opts; exit 0; }
|
58
|
+
true if opts.parse!(@args) rescue return false
|
59
|
+
end
|
60
|
+
|
61
|
+
def get_arguments
|
62
|
+
if @args.nitems >= 2
|
63
|
+
@config.infile = Pathname.new(@args.shift)
|
64
|
+
@config.outfile = Pathname.new(@args.shift)
|
65
|
+
return true
|
66
|
+
else
|
67
|
+
return false
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def write_premail_html
|
72
|
+
puts "Converting HTML to inline CSS and saving as #{@config.outfile.basename.to_s}..." if @config.verbose
|
73
|
+
@config.outfile.open("w") do |file|
|
74
|
+
file.write(@premailer.to_inline_css)
|
75
|
+
end
|
76
|
+
puts "Done." if @config.verbose
|
77
|
+
end
|
78
|
+
|
79
|
+
def write_plain_text
|
80
|
+
textfile = Pathname.new(File.join(@config.outfile.dirname.to_s, @config.outfile.basename.to_s.gsub(/\..+$/, ".txt")))
|
81
|
+
puts "Saving a plain-text version of your email as #{@config.outfile.basename.to_s.gsub(/\.html$/, '.txt')}..." if @config.verbose
|
82
|
+
puts "Shortening link URLs with Bit.ly..." if @config.verbose and @config.shorten_urls
|
83
|
+
textfile.open("w") do |file|
|
84
|
+
file.write(@premailer.to_plain_text)
|
85
|
+
end
|
86
|
+
puts "Done." if @config.verbose
|
87
|
+
end
|
88
|
+
|
89
|
+
def show_warnings
|
90
|
+
puts "#{@premailer.warnings.nitems.to_s} warnings found:"
|
91
|
+
@premailer.warnings.each do |warning|
|
92
|
+
puts warning
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
app = PremailerApp.new(ARGV)
|
98
|
+
app.run
|
99
|
+
|
100
|
+
__END__
|
101
|
+
Premailer Plus
|
102
|
+
-----------------
|
103
|
+
|
104
|
+
Usage:
|
105
|
+
FILE [options] inputfile outputfile
|
106
|
+
"inputfile" can be either a local file or a URL
|
107
|
+
|
108
|
+
Options:
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'text/reform'
|
2
|
+
require 'htmlentities'
|
3
|
+
require 'bitly4r'
|
4
|
+
|
5
|
+
# Support functions for Premailer
|
6
|
+
module HtmlToPlainText
|
7
|
+
|
8
|
+
# Returns the text in UTF-8 format with all HTML tags removed
|
9
|
+
#
|
10
|
+
# TODO:
|
11
|
+
# - add support for DL, OL
|
12
|
+
def convert_to_text(html, line_length, from_charset = 'UTF-8', shorten = false)
|
13
|
+
r = Text::Reform.new(:trim => true,
|
14
|
+
:squeeze => false,
|
15
|
+
:break => Text::Reform.break_wrap)
|
16
|
+
|
17
|
+
txt = html
|
18
|
+
|
19
|
+
bitly = shorten ? Bitly4R.Keyed("mikedamage", "R_abb45e99634386334b7ed6c8d081e80e") : nil
|
20
|
+
|
21
|
+
he = HTMLEntities.new # decode HTML entities
|
22
|
+
|
23
|
+
txt = he.decode(txt)
|
24
|
+
|
25
|
+
txt.gsub!(/<h([0-9]+)[^>]*>(.*)<\/h[0-9]+>/i) do |s| # handle headings
|
26
|
+
hlevel = $1.to_i
|
27
|
+
htext = $2.gsub(/<\/?[^>]*>/i, '') # remove tags inside headings
|
28
|
+
hlength = (htext.length > line_length ?
|
29
|
+
line_length :
|
30
|
+
htext.length)
|
31
|
+
|
32
|
+
case hlevel
|
33
|
+
when 1 # H1
|
34
|
+
('*' * hlength) + "\n" + htext + "\n" + ('*' * hlength) + "\n"
|
35
|
+
when 2 # H2
|
36
|
+
('-' * hlength) + "\n" + htext + "\n" + ('-' * hlength) + "\n"
|
37
|
+
else # H3-H6 are styled the same
|
38
|
+
htext + "\n" + ('-' * htext.length) + "\n"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
txt.gsub!(/<a.*href=\"([^\"]*)\"[^>]*>(.*)<\/a>/i) do |s| # links
|
43
|
+
if bitly
|
44
|
+
$2 + ' [' + bitly.shorten($1) + ']'
|
45
|
+
else
|
46
|
+
$2 + ' [' + $1 + ']'
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
txt.gsub!(/(<li[\s]+[^>]*>|<li>)/i, ' * ') # unordered LIsts
|
51
|
+
txt.gsub!(/<\/p>/i, "\n\n") # paragraphs
|
52
|
+
|
53
|
+
txt.gsub!(/<\/?[^>]*>/, '') # strip remaining tags
|
54
|
+
txt.gsub!(/\A[\s]+|[\s]+\Z|^[ \t]+/m, '') # strip extra spaces
|
55
|
+
txt.gsub!(/[\n]{3,}/m, "\n\n") # tighten line breaks
|
56
|
+
|
57
|
+
txt = r.format(('[' * line_length), txt) # wrap text
|
58
|
+
txt.gsub!(/^[\*][\s]/m, ' * ') # add spaces back to lists
|
59
|
+
txt
|
60
|
+
end
|
61
|
+
end
|
data/lib/premailer.rb
ADDED
@@ -0,0 +1,396 @@
|
|
1
|
+
#!/usr/bin/ruby
|
2
|
+
#
|
3
|
+
# Premailer by Alex Dunae (dunae.ca, e-mail 'code' at the same domain), 2008
|
4
|
+
# Version 1.5.0
|
5
|
+
|
6
|
+
ENV["GEM_PATH"] = "/Library/Ruby/Gems/1.8"
|
7
|
+
|
8
|
+
$LOAD_PATH.unshift(File.expand_path(File.dirname(__FILE__), ''))
|
9
|
+
|
10
|
+
require 'rubygems'
|
11
|
+
require 'yaml'
|
12
|
+
require 'open-uri'
|
13
|
+
require 'hpricot'
|
14
|
+
require 'css_parser'
|
15
|
+
|
16
|
+
require 'html_to_plain_text'
|
17
|
+
|
18
|
+
# Premailer processes HTML and CSS to improve e-mail deliverability.
|
19
|
+
#
|
20
|
+
# Premailer's main function is to render all CSS as inline <tt>style</tt> attributes using
|
21
|
+
# the CssParser. It can also convert relative links to absolute links and check the 'safety' of
|
22
|
+
# CSS properties against a CSS support chart.
|
23
|
+
#
|
24
|
+
# = Example
|
25
|
+
#
|
26
|
+
# premailer = Premailer.new(html_file, :warn_level => Premailer::Warnings::SAFE)
|
27
|
+
# premailer.parse!
|
28
|
+
# puts premailer.warnings.length.to_s + ' warnings found'
|
29
|
+
class Premailer
|
30
|
+
include HtmlToPlainText
|
31
|
+
include CssParser
|
32
|
+
|
33
|
+
CLIENT_SUPPORT_FILE = File.dirname(__FILE__) + '/../misc/client_support.yaml'
|
34
|
+
|
35
|
+
RE_UNMERGABLE_SELECTORS = /(\:(visited|active|hover|focus|after|before|selection|target|first\-(line|letter))|^\@)/i
|
36
|
+
|
37
|
+
# should also exclude :first-letter, etc...
|
38
|
+
|
39
|
+
# URI of the HTML file used
|
40
|
+
attr_reader :html_file
|
41
|
+
|
42
|
+
module Warnings
|
43
|
+
NONE = 0
|
44
|
+
SAFE = 1
|
45
|
+
POOR = 2
|
46
|
+
RISKY = 3
|
47
|
+
end
|
48
|
+
include Warnings
|
49
|
+
|
50
|
+
WARN_LABEL = %w(NONE SAFE POOR RISKY)
|
51
|
+
|
52
|
+
# Create a new Premailer object.
|
53
|
+
#
|
54
|
+
# +uri+ is the URL of the HTML file to process. Should be a string.
|
55
|
+
#
|
56
|
+
# ==== Options
|
57
|
+
# [+line_length+] Line length used by to_plain_text. Boolean, default is 65.
|
58
|
+
# [+warn_level+] What level of CSS compatibility warnings to show (see Warnings).
|
59
|
+
# [+link_query_string+] A string to append to every <a href=""> link.
|
60
|
+
def initialize(uri, options = {})
|
61
|
+
@options = {:warn_level => Warnings::SAFE, :line_length => 65, :link_query_string => nil, :base_url => nil, :shorten_urls => false}.merge(options)
|
62
|
+
@html_file = uri
|
63
|
+
|
64
|
+
|
65
|
+
@is_local_file = true
|
66
|
+
if uri =~ /^(http|https|ftp)\:\/\//i
|
67
|
+
@is_local_file = false
|
68
|
+
end
|
69
|
+
|
70
|
+
|
71
|
+
@css_warnings = []
|
72
|
+
|
73
|
+
@css_parser = CssParser::Parser.new({:absolute_paths => true,
|
74
|
+
:import => true,
|
75
|
+
:io_exceptions => false
|
76
|
+
})
|
77
|
+
|
78
|
+
@doc, @html_charset = load_html(@html_file)
|
79
|
+
|
80
|
+
if @is_local_file and @options[:base_url]
|
81
|
+
@doc = convert_inline_links(@doc, @options[:base_url])
|
82
|
+
elsif not @is_local_file
|
83
|
+
@doc = convert_inline_links(@doc, @html_file)
|
84
|
+
end
|
85
|
+
load_css_from_html!
|
86
|
+
end
|
87
|
+
|
88
|
+
# Array containing a hash of CSS warnings.
|
89
|
+
def warnings
|
90
|
+
return [] if @options[:warn_level] == Warnings::NONE
|
91
|
+
@css_warnings = check_client_support if @css_warnings.empty?
|
92
|
+
@css_warnings
|
93
|
+
end
|
94
|
+
|
95
|
+
# Returns the original HTML as a string.
|
96
|
+
def to_s
|
97
|
+
@doc.to_html
|
98
|
+
end
|
99
|
+
|
100
|
+
# Returns the document with all HTML tags removed.
|
101
|
+
def to_plain_text
|
102
|
+
html_src = ''
|
103
|
+
begin
|
104
|
+
html_src = @doc.search("body").innerHTML
|
105
|
+
rescue
|
106
|
+
html_src = @doc.to_html
|
107
|
+
end
|
108
|
+
if @options[:shorten_urls]
|
109
|
+
convert_to_text(html_src, @options[:line_length], @html_charset, true)
|
110
|
+
else
|
111
|
+
convert_to_text(html_src, @options[:line_length], @html_charset, false)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# Merge CSS into the HTML document.
|
116
|
+
#
|
117
|
+
# Returns a string.
|
118
|
+
def to_inline_css
|
119
|
+
doc = @doc
|
120
|
+
unmergable_rules = CssParser::Parser.new
|
121
|
+
|
122
|
+
# Give all styles already in style attributes a specificity of 1000
|
123
|
+
# per http://www.w3.org/TR/CSS21/cascade.html#specificity
|
124
|
+
doc.search("*[@style]").each do |el|
|
125
|
+
el['style'] = '[SPEC=1000[' + el.attributes['style'] + ']]'
|
126
|
+
end
|
127
|
+
|
128
|
+
# Iterate through the rules and merge them into the HTML
|
129
|
+
@css_parser.each_selector(:all) do |selector, declaration, specificity|
|
130
|
+
# Save un-mergable rules separately
|
131
|
+
selector.gsub!(/:link([\s]|$)+/i, '')
|
132
|
+
|
133
|
+
# Convert element names to lower case
|
134
|
+
selector.gsub!(/([\s]|^)([\w]+)/) {|m| $1.to_s + $2.to_s.downcase }
|
135
|
+
|
136
|
+
if selector =~ RE_UNMERGABLE_SELECTORS
|
137
|
+
unmergable_rules.add_rule_set!(RuleSet.new(selector, declaration))
|
138
|
+
else
|
139
|
+
|
140
|
+
doc.search(selector) do |el|
|
141
|
+
if el.elem?
|
142
|
+
# Add a style attribute or append to the existing one
|
143
|
+
block = "[SPEC=#{specificity}[#{declaration}]]"
|
144
|
+
el['style'] = (el.attributes['style'] ||= '') + ' ' + block
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
# Read <style> attributes and perform folding
|
151
|
+
doc.search("*[@style]").each do |el|
|
152
|
+
style = el.attributes['style'].to_s
|
153
|
+
|
154
|
+
declarations = []
|
155
|
+
|
156
|
+
style.scan(/\[SPEC\=([\d]+)\[(.[^\]\]]*)\]\]/).each do |declaration|
|
157
|
+
rs = RuleSet.new(nil, declaration[1].to_s, declaration[0].to_i)
|
158
|
+
declarations << rs
|
159
|
+
end
|
160
|
+
|
161
|
+
# Perform style folding and save
|
162
|
+
merged = CssParser.merge(declarations)
|
163
|
+
|
164
|
+
el['style'] = Premailer.escape_string(merged.declarations_to_s)
|
165
|
+
end
|
166
|
+
|
167
|
+
doc = write_unmergable_css_rules(doc, unmergable_rules)
|
168
|
+
|
169
|
+
#doc = add_body_imposter(doc)
|
170
|
+
|
171
|
+
doc.to_html
|
172
|
+
end
|
173
|
+
|
174
|
+
|
175
|
+
protected
|
176
|
+
# Load the HTML file and convert it into an Hpricot document.
|
177
|
+
#
|
178
|
+
# Returns an Hpricot document and a string with the HTML file's character set.
|
179
|
+
def load_html(uri)
|
180
|
+
if @is_local_file
|
181
|
+
Hpricot(File.open(uri, "r") {|f| f.read })
|
182
|
+
else
|
183
|
+
Hpricot(open(uri))
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
# Load CSS included in <tt>style</tt> and <tt>link</tt> tags from an HTML document.
|
188
|
+
def load_css_from_html!
|
189
|
+
if tags = @doc.search("link[@rel='stylesheet'], style")
|
190
|
+
tags.each do |tag|
|
191
|
+
if tag.to_s.strip =~ /^\<link/i and tag.attributes['href']
|
192
|
+
if media_type_ok?(tag.attributes['media'])
|
193
|
+
link_uri = Premailer.resolve_link(tag.attributes['href'].to_s, @html_file)
|
194
|
+
if @is_local_file
|
195
|
+
css_block = ''
|
196
|
+
File.open(link_uri, "r") do |file|
|
197
|
+
while line = file.gets
|
198
|
+
css_block << line
|
199
|
+
end
|
200
|
+
end
|
201
|
+
@css_parser.add_block!(css_block, {:base_uri => @html_file})
|
202
|
+
else
|
203
|
+
@css_parser.load_uri!(link_uri)
|
204
|
+
end
|
205
|
+
end
|
206
|
+
elsif tag.to_s.strip =~ /^\<style/i
|
207
|
+
@css_parser.add_block!(tag.innerHTML, :base_uri => URI.parse(@html_file))
|
208
|
+
end
|
209
|
+
end
|
210
|
+
tags.remove
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
def media_type_ok?(media_types)
|
215
|
+
return media_types.split(/[\s]+|,/).any? { |media_type| media_type.strip =~ /screen|handheld|all/i }
|
216
|
+
rescue
|
217
|
+
return true
|
218
|
+
end
|
219
|
+
|
220
|
+
# Create a <tt>style</tt> element with un-mergable rules (e.g. <tt>:hover</tt>)
|
221
|
+
# and write it into the <tt>body</tt>.
|
222
|
+
#
|
223
|
+
# <tt>doc</tt> is an Hpricot document and <tt>unmergable_css_rules</tt> is a Css::RuleSet.
|
224
|
+
#
|
225
|
+
# Returns an Hpricot document.
|
226
|
+
def write_unmergable_css_rules(doc, unmergable_rules)
|
227
|
+
styles = ''
|
228
|
+
unmergable_rules.each_selector(:all, :force_important => true) do |selector, declarations, specificity|
|
229
|
+
styles += "#{selector} { #{declarations} }\n"
|
230
|
+
end
|
231
|
+
unless styles.empty?
|
232
|
+
style_tag = "\n<style type=\"text/css\">\n#{styles}</style>\n"
|
233
|
+
doc.search("head").append(style_tag)
|
234
|
+
end
|
235
|
+
doc
|
236
|
+
end
|
237
|
+
|
238
|
+
# Convert relative links to absolute links.
|
239
|
+
#
|
240
|
+
# Processes <tt>href</tt> <tt>src</tt> and <tt>background</tt> attributes
|
241
|
+
# as well as CSS <tt>url()</tt> declarations found in inline <tt>style</tt> attributes.
|
242
|
+
#
|
243
|
+
# <tt>doc</tt> is an Hpricot document and <tt>base_uri</tt> is either a string or a URI.
|
244
|
+
#
|
245
|
+
# Returns an Hpricot document.
|
246
|
+
def convert_inline_links(doc, base_uri)
|
247
|
+
base_uri = URI.parse(base_uri) unless base_uri.kind_of?(URI)
|
248
|
+
|
249
|
+
['href', 'src', 'background'].each do |attribute|
|
250
|
+
|
251
|
+
tags = doc.search("*[@#{attribute}]")
|
252
|
+
append_qs = @options[:link_query_string] ||= ''
|
253
|
+
unless tags.empty?
|
254
|
+
tags.each do |tag|
|
255
|
+
unless tag.attributes[attribute] =~ /^(\{|\[|<|\#)/i
|
256
|
+
if tag.attributes[attribute] =~ /^http/i
|
257
|
+
begin
|
258
|
+
merged = URI.parse(tag.attributes[attribute])
|
259
|
+
rescue
|
260
|
+
next
|
261
|
+
end
|
262
|
+
else
|
263
|
+
begin
|
264
|
+
merged = Premailer.resolve_link(tag.attributes[attribute].to_s, base_uri.merge)
|
265
|
+
rescue
|
266
|
+
begin
|
267
|
+
merged = Premailer.resolve_link(URI.escape(tag.attributes[attribute].to_s), base_uri.merge)
|
268
|
+
# merged = base_uri.merge(URI.escape(tag.attributes[attribute].to_s))
|
269
|
+
rescue; end
|
270
|
+
end
|
271
|
+
end # end of relative urls only
|
272
|
+
|
273
|
+
if tag.name =~ /^a$/i and not append_qs.empty?
|
274
|
+
if merged.query
|
275
|
+
merged.query = merged.query + '&' + append_qs
|
276
|
+
else
|
277
|
+
merged.query = append_qs
|
278
|
+
end
|
279
|
+
end
|
280
|
+
tag[attribute] = merged
|
281
|
+
#puts merged.inspect
|
282
|
+
end # end of skipping special chars
|
283
|
+
|
284
|
+
|
285
|
+
end # end of each tag
|
286
|
+
end # end of empty
|
287
|
+
end # end of attrs
|
288
|
+
|
289
|
+
doc.search("*[@style]").each do |el|
|
290
|
+
el['style'] = CssParser.convert_uris(el.attributes['style'].to_s, base_uri)
|
291
|
+
end
|
292
|
+
doc
|
293
|
+
end
|
294
|
+
|
295
|
+
def self.escape_string(str)
|
296
|
+
str.gsub(/"/, "'")
|
297
|
+
end
|
298
|
+
|
299
|
+
def self.resolve_link(path, base_path)
|
300
|
+
if base_path.kind_of?(URI)
|
301
|
+
base_path.merge!(path)
|
302
|
+
return Premailer.canonicalize(base_path)
|
303
|
+
elsif base_path.kind_of?(String) and base_path =~ /^(http[s]?|ftp):\/\//i
|
304
|
+
base_uri = URI.parse(base_path)
|
305
|
+
base_uri.merge!(path)
|
306
|
+
return Premailer.canonicalize(base_uri)
|
307
|
+
else
|
308
|
+
|
309
|
+
return File.expand_path(path, File.dirname(base_path))
|
310
|
+
end
|
311
|
+
end
|
312
|
+
|
313
|
+
# from http://www.ruby-forum.com/topic/140101
|
314
|
+
def self.canonicalize(uri)
|
315
|
+
u = uri.kind_of?(URI) ? uri : URI.parse(uri.to_s)
|
316
|
+
u.normalize!
|
317
|
+
newpath = u.path
|
318
|
+
while newpath.gsub!(%r{([^/]+)/\.\./?}) { |match|
|
319
|
+
$1 == '..' ? match : ''
|
320
|
+
} do end
|
321
|
+
newpath = newpath.gsub(%r{/\./}, '/').sub(%r{/\.\z}, '/')
|
322
|
+
u.path = newpath
|
323
|
+
u.to_s
|
324
|
+
end
|
325
|
+
|
326
|
+
|
327
|
+
|
328
|
+
def add_body_imposter(doc)
|
329
|
+
newdoc = doc
|
330
|
+
if body_tag = newdoc.at("body") and body_tag.attributes["style"]
|
331
|
+
body_html = body_tag.inner_html
|
332
|
+
body_tag.inner_html = "\n<div id=\"premailer_body_wrapper\">\n#{body_html}\n</div>\n"
|
333
|
+
if body_tag.attributes["style"]
|
334
|
+
newdoc.at("#premailer_body_wrapper")["style"] = body_tag.attributes["style"].to_s
|
335
|
+
newdoc.at("body")["style"] = "margin: 0; padding: 0;"
|
336
|
+
end
|
337
|
+
|
338
|
+
end
|
339
|
+
return newdoc
|
340
|
+
rescue
|
341
|
+
return doc
|
342
|
+
end
|
343
|
+
|
344
|
+
|
345
|
+
# Check <tt>CLIENT_SUPPORT_FILE</tt> for any CSS warnings
|
346
|
+
def check_client_support
|
347
|
+
@client_support = @client_support ||= YAML::load(File.open(CLIENT_SUPPORT_FILE))
|
348
|
+
|
349
|
+
warnings = []
|
350
|
+
properties = []
|
351
|
+
|
352
|
+
# Get a list off CSS properties
|
353
|
+
@doc.search("*[@style]").each do |el|
|
354
|
+
style_url = el.attributes['style'].gsub(/([\w\-]+)[\s]*\:/i) do |s|
|
355
|
+
properties.push($1)
|
356
|
+
end
|
357
|
+
end
|
358
|
+
|
359
|
+
properties.uniq!
|
360
|
+
|
361
|
+
property_support = @client_support['css_properties']
|
362
|
+
properties.each do |prop|
|
363
|
+
if property_support.include?(prop) and property_support[prop]['support'] >= @options[:warn_level]
|
364
|
+
warnings.push({:message => "#{prop} CSS property",
|
365
|
+
:level => WARN_LABEL[property_support[prop]['support']],
|
366
|
+
:clients => property_support[prop]['unsupported_in'].join(', ')})
|
367
|
+
end
|
368
|
+
end
|
369
|
+
|
370
|
+
@client_support['attributes'].each do |attribute, data|
|
371
|
+
next unless data['support'] >= @options[:warn_level]
|
372
|
+
if @doc.search("*[@#{attribute}]").length > 0
|
373
|
+
warnings.push({:message => "#{attribute} HTML attribute",
|
374
|
+
:level => WARN_LABEL[property_support[prop]['support']],
|
375
|
+
:clients => property_support[prop]['unsupported_in'].join(', ')})
|
376
|
+
end
|
377
|
+
end
|
378
|
+
|
379
|
+
@client_support['elements'].each do |element, data|
|
380
|
+
next unless data['support'] >= @options[:warn_level]
|
381
|
+
if @doc.search("element").length > 0
|
382
|
+
warnings.push({:message => "#{element} HTML element",
|
383
|
+
:level => WARN_LABEL[property_support[prop]['support']],
|
384
|
+
:clients => property_support[prop]['unsupported_in'].join(', ')})
|
385
|
+
end
|
386
|
+
end
|
387
|
+
|
388
|
+
|
389
|
+
|
390
|
+
|
391
|
+
|
392
|
+
return warnings
|
393
|
+
end
|
394
|
+
end
|
395
|
+
|
396
|
+
|