Repository: alexdunae/premailer Branch: master Commit: be30faeeec0a Files: 41 Total size: 125.5 KB Directory structure: gitextract_7t_qp4dq/ ├── .gitignore ├── .jrubyrc ├── .travis.yml ├── .yardopts ├── Gemfile ├── LICENSE.md ├── README.md ├── Rakefile ├── bin/ │ └── premailer ├── lib/ │ ├── premailer/ │ │ ├── adapter/ │ │ │ ├── hpricot.rb │ │ │ ├── nokogiri.rb │ │ │ └── nokogumbo.rb │ │ ├── adapter.rb │ │ ├── executor.rb │ │ ├── html_to_plain_text.rb │ │ ├── premailer.rb │ │ └── version.rb │ └── premailer.rb ├── misc/ │ └── client_support.yaml ├── premailer.gemspec └── test/ ├── files/ │ ├── base.html │ ├── chars.html │ ├── html4.html │ ├── html_with_uri.html │ ├── ignore.css │ ├── ignore.html │ ├── import.css │ ├── iso-8859-2.html │ ├── iso-8859-5.html │ ├── no_css.html │ ├── noimport.css │ ├── styles.css │ └── xhtml.html ├── future_tests.rb ├── helper.rb ├── test_adapter.rb ├── test_html_to_plain_text.rb ├── test_links.rb ├── test_misc.rb ├── test_premailer.rb └── test_warnings.rb ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ .DS_Store *.gem bin/*.html html/ vendor/ doc/ .yardoc/ *.sw? pkg/ .bundle/ *.sublime-* ================================================ FILE: .jrubyrc ================================================ cext.enabled=true ================================================ FILE: .travis.yml ================================================ cache: bundler sudo: false branches: only: master matrix: fast_finish: true before_install: rm Gemfile.lock rvm: - 2.0.0 - 2.1.0 - 2.2.0 - 2.3.0 ================================================ FILE: .yardopts ================================================ --markup markdown --markup-provider redcarpet --charset utf-8 --no-private --readme README.md --title "Premailer Documentation" - README.md LICENSE.md ================================================ FILE: Gemfile ================================================ source "https://rubygems.org" gem 'css_parser', :git => 'git://github.com/premailer/css_parser.git' platforms :jruby do gem 'jruby-openssl' end gemspec ================================================ FILE: LICENSE.md ================================================ # Premailer License Copyright (c) 2007-2012, Alex Dunae. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Premailer, Alex Dunae nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: README.md ================================================ # Premailer README [![Build Status](https://travis-ci.org/premailer/premailer.png?branch=master)](https://travis-ci.org/premailer/premailer) ## What is this? For the best HTML e-mail delivery results, CSS should be inline. This is a huge pain and a simple newsletter becomes un-managable very quickly. This script is my solution. * CSS styles are converted to inline style attributes - Checks `style` and `link[rel=stylesheet]` tags and preserves existing inline attributes * Relative paths are converted to absolute paths - Checks links in `href`, `src` and CSS `url('')` * CSS properties are checked against e-mail client capabilities - Based on the Email Standards Project's guides * A plain text version is created (optional) ## Premailer 2.0 is coming I'm looking for input on a version 2.0 update to Premailer. Please visit the [Premailer 2.0 Planning Page](https://github.com/premailer/premailer/wiki/New-Premailer-2.0-Planning) and give me your feedback. ## Installation Install the Premailer gem from RubyGems. ```bash gem install premailer ``` or add it to your `Gemfile` and run `bundle`. ## Example ```ruby require 'rubygems' # optional for Ruby 1.9 or above. require 'premailer' premailer = Premailer.new('http://example.com/myfile.html', :warn_level => Premailer::Warnings::SAFE) # Write the HTML output File.open("output.html", "w") do |fout| fout.puts premailer.to_inline_css end # Write the plain-text output File.open("output.txt", "w") do |fout| fout.puts premailer.to_plain_text end # Output any CSS warnings premailer.warnings.each do |w| puts "#{w[:message]} (#{w[:level]}) may not render properly in #{w[:clients]}" end ``` ## Ruby Compatibility Premailer is tested on Ruby 1.8.7, Ruby 1.9.2, Ruby 1.9.3, and Ruby 2.x.0 . It also works on REE. JRuby support is close; contributors are welcome. Checkout the latest build status on the [Travis CI dashboard](https://travis-ci.org/#!/premailer/premailer). ## Premailer-specific CSS Premailer looks for a few CSS attributes that make working with tables a bit easier. | CSS Attribute | Availability | | ------------- | ------------ | | -premailer-width | Available on `table`, `th` and `td` elements | | -premailer-height | Available on `table`, `tr`, `th` and `td` elements | | -premailer-cellpadding | Available on `table` elements | | -premailer-cellspacing | Available on `table` elements | | data-premailer="ignore" | Available on `link` and `style` elements. Premailer will ignore these elements entirely. | Each of these CSS declarations will be copied to appropriate element's attribute. For example ```css table { -premailer-cellspacing: 5; -premailer-width: 500; } ``` will result in ```html ``` ## Contributions Contributions are most welcome. Premailer was rotting away in a private SVN repository for too long and could use some TLC. Fork and patch to your heart's content. Please don't increment the version numbers, though. A few areas that are particularly in need of love: * Improved test coverage * Move un-repeated background images defined in CSS for Outlook ## Credits and code Thanks to [all the wonderful contributors](https://github.com/premailer/premailer/contributors) for their updates. Thanks to [Greenhood + Company](http://www.greenhood.com/) for sponsoring some of the 1.5.6 updates, and to [Campaign Monitor](https://www.campaignmonitor.com/) for supporting the web interface. The web interface can be found at [premailer.dialect.ca](http://premailer.dialect.ca). The source code can be found on [GitHub](https://github.com/alexdunae/premailer). Copyright by Alex Dunae (dunae.ca, e-mail 'code' at the same domain), 2007-2012. See [LICENSE.md](https://github.com/alexdunae/premailer/blob/master/LICENSE.md) for license details. ================================================ FILE: Rakefile ================================================ require 'bundler/setup' require 'rake/testtask' require "bundler/gem_tasks" require 'yard' GEM_ROOT = File.dirname(__FILE__).freeze unless defined?(GEM_ROOT) lib_path = File.expand_path('lib', GEM_ROOT) $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include? lib_path require 'premailer/version' desc 'Parse a URL and write out the output.' task :inline do require 'premailer' url = ENV['url'] output = ENV['output'] if !url or url.empty? or !output or output.empty? puts 'Usage: rake inline url=http://example.com/ output=output.html' exit end premailer = Premailer.new(url, :warn_level => Premailer::Warnings::SAFE, :verbose => true, :adapter => :nokogiri) File.open(output, "w") do |fout| fout.puts premailer.to_inline_css end puts "Succesfully parsed '#{url}' into '#{output}'" puts premailer.warnings.length.to_s + ' CSS warnings were found' end task :text do require 'premailer' url = ENV['url'] output = ENV['output'] if !url or url.empty? or !output or output.empty? puts 'Usage: rake text url=http://example.com/ output=output.txt' exit end premailer = Premailer.new(url, :warn_level => Premailer::Warnings::SAFE) File.open(output, "w") do |fout| fout.puts premailer.to_plain_text end puts "Succesfully parsed '#{url}' into '#{output}'" end Rake::TestTask.new do |t| t.test_files = FileList['test/test_*.rb'] t.verbose = false t.warning = false end YARD::Rake::YardocTask.new do |yard| yard.options << "--title='Premailer #{Premailer::VERSION} Documentation'" end task :default => [:test] ================================================ FILE: bin/premailer ================================================ #!/usr/bin/env ruby # This binary used in rubygems environment only as part of installed gem require 'premailer/executor' ================================================ FILE: lib/premailer/adapter/hpricot.rb ================================================ require 'hpricot' class Premailer module Adapter # Hpricot adapter module Hpricot # Merge CSS into the HTML document. # @return [String] HTML. def to_inline_css doc = @processed_doc @unmergable_rules = CssParser::Parser.new # Give all styles already in style attributes a specificity of 1000 # per http://www.w3.org/TR/CSS21/cascade.html#specificity doc.search("*[@style]").each do |el| el['style'] = '[SPEC=1000[' + el.attributes['style'] + ']]' end # Iterate through the rules and merge them into the HTML @css_parser.each_selector(:all) do |selector, declaration, specificity, media_types| # Save un-mergable rules separately selector.gsub!(/:link([\s]*)+/i) {|m| $1 } # Convert element names to lower case selector.gsub!(/([\s]|^)([\w]+)/) {|m| $1.to_s + $2.to_s.downcase } if Premailer.is_media_query?(media_types) || selector =~ Premailer::RE_UNMERGABLE_SELECTORS @unmergable_rules.add_rule_set!(CssParser::RuleSet.new(selector, declaration), media_types) unless @options[:preserve_styles] else begin if selector =~ Premailer::RE_RESET_SELECTORS # this is in place to preserve the MailChimp CSS reset: http://github.com/mailchimp/Email-Blueprints/ # however, this doesn't mean for testing pur @unmergable_rules.add_rule_set!(CssParser::RuleSet.new(selector, declaration)) unless !@options[:preserve_reset] end # Change single ID CSS selectors into xpath so that we can match more # than one element. Added to work around dodgy generated code. selector.gsub!(/\A\#([\w_\-]+)\Z/, '*[@id=\1]') # convert attribute selectors to hpricot's format selector.gsub!(/\[([\w]+)\]/, '[@\1]') selector.gsub!(/\[([\w]+)([\=\~\^\$\*]+)([\w\s]+)\]/, '[@\1\2\'\3\']') doc.search(selector).each do |el| if el.elem? and (el.name != 'head' and el.parent.name != 'head') # Add a style attribute or append to the existing one block = "[SPEC=#{specificity}[#{declaration}]]" el['style'] = (el.attributes['style'].to_s ||= '') + ' ' + block end end rescue ::Hpricot::Error, RuntimeError, ArgumentError $stderr.puts "CSS syntax error with selector: #{selector}" if @options[:verbose] next end end end # Remove script tags if @options[:remove_scripts] doc.search("script").remove end # Read STYLE attributes and perform folding doc.search("*[@style]").each do |el| style = el.attributes['style'].to_s declarations = [] style.scan(/\[SPEC\=([\d]+)\[(.[^\]\]]*)\]\]/).each do |declaration| rs = CssParser::RuleSet.new(nil, declaration[1].to_s, declaration[0].to_i) declarations << rs end # Perform style folding merged = CssParser.merge(declarations) merged.expand_shorthand! merged.create_shorthand! if @options[:create_shorthands] # Duplicate CSS attributes as HTML attributes if Premailer::RELATED_ATTRIBUTES.has_key?(el.name) && @options[:css_to_attributes] Premailer::RELATED_ATTRIBUTES[el.name].each do |css_att, html_att| el[html_att] = merged[css_att].gsub(/url\('(.*)'\)/,'\1').gsub(/;$|\s*!important/, '').strip if el[html_att].nil? and not merged[css_att].empty? merged.instance_variable_get("@declarations").tap do |declarations| declarations.delete(css_att) end end end # write the inline STYLE attribute el['style'] = Premailer.escape_string(merged.declarations_to_s) end doc = write_unmergable_css_rules(doc, @unmergable_rules) if @options[:remove_classes] or @options[:remove_comments] doc.search('*').each do |el| if el.comment? and @options[:remove_comments] lst = el.parent.children el.parent = nil lst.delete(el) elsif el.elem? el.remove_attribute('class') if @options[:remove_classes] end end end if @options[:reset_contenteditable] doc.search('*[@contenteditable]').each do |el| el.remove_attribute('contenteditable') end end if @options[:remove_ids] # find all anchor's targets and hash them targets = [] doc.search("a[@href^='#']").each do |el| target = el.get_attribute('href')[1..-1] targets << target el.set_attribute('href', "#" + Digest::MD5.hexdigest(target)) end # hash ids that are links target, delete others doc.search("*[@id]").each do |el| id = el.get_attribute('id') if targets.include?(id) el.set_attribute('id', Digest::MD5.hexdigest(id)) else el.remove_attribute('id') end end end @processed_doc = doc @processed_doc.to_original_html end # Create a style element with un-mergable rules (e.g. :hover) # and write it into the body. # # doc is an Hpricot document and unmergable_css_rules is a Css::RuleSet. # # @return [::Hpricot] a document. def write_unmergable_css_rules(doc, unmergable_rules) # :nodoc: styles = unmergable_rules.to_s unless styles.empty? style_tag = "\n\n" if head = doc.search('head') head.append(style_tag) elsif body = doc.search('body') body.append(style_tag) else doc.inner_html= doc.inner_html << style_tag end end doc end # Converts the HTML document to a format suitable for plain-text e-mail. # # If present, uses the element as its base; otherwise uses the whole document. # # @return [String] Plain text. def to_plain_text html_src = '' begin html_src = @doc.search("body").inner_html rescue; end html_src = @doc.to_html unless html_src and not html_src.empty? convert_to_text(html_src, @options[:line_length], @html_encoding) end # Gets the original HTML as a string. # @return [String] HTML. def to_s @doc.to_original_html end # Load the HTML file and convert it into an Hpricot document. # # @return [::Hpricot] a document. def load_html(input) # :nodoc: thing = nil # TODO: duplicate options if @options[:with_html_string] or @options[:inline] or input.respond_to?(:read) thing = input elsif @is_local_file @base_dir = File.dirname(input) thing = File.open(input, 'r') else thing = open(input) end # TODO: deal with Hpricot seg faults on empty input thing ? Hpricot(thing) : nil end end end end ================================================ FILE: lib/premailer/adapter/nokogiri.rb ================================================ require 'nokogiri' class Premailer module Adapter # Nokogiri adapter module Nokogiri # Merge CSS into the HTML document. # # @return [String] an HTML. def to_inline_css doc = @processed_doc @unmergable_rules = CssParser::Parser.new # Give all styles already in style attributes a specificity of 1000 # per http://www.w3.org/TR/CSS21/cascade.html#specificity doc.search("*[@style]").each do |el| el['style'] = '[SPEC=1000[' + el.attributes['style'] + ']]' end # Iterate through the rules and merge them into the HTML @css_parser.each_selector(:all) do |selector, declaration, specificity, media_types| # Save un-mergable rules separately selector.gsub!(/:link([\s]*)+/i) { |m| $1 } # Convert element names to lower case selector.gsub!(/([\s]|^)([\w]+)/) { |m| $1.to_s + $2.to_s.downcase } if Premailer.is_media_query?(media_types) || selector =~ Premailer::RE_UNMERGABLE_SELECTORS @unmergable_rules.add_rule_set!(CssParser::RuleSet.new(selector, declaration), media_types) unless @options[:preserve_styles] else begin if selector =~ Premailer::RE_RESET_SELECTORS # this is in place to preserve the MailChimp CSS reset: http://github.com/mailchimp/Email-Blueprints/ # however, this doesn't mean for testing pur @unmergable_rules.add_rule_set!(CssParser::RuleSet.new(selector, declaration)) unless !@options[:preserve_reset] end # Change single ID CSS selectors into xpath so that we can match more # than one element. Added to work around dodgy generated code. selector.gsub!(/\A\#([\w_\-]+)\Z/, '*[@id=\1]') doc.search(selector).each do |el| if el.elem? and (el.name != 'head' and el.parent.name != 'head') # Add a style attribute or append to the existing one block = "[SPEC=#{specificity}[#{declaration}]]" el['style'] = (el.attributes['style'].to_s ||= '') + ' ' + block end end rescue ::Nokogiri::SyntaxError, RuntimeError, ArgumentError $stderr.puts "CSS syntax error with selector: #{selector}" if @options[:verbose] next end end end # Remove script tags if @options[:remove_scripts] doc.search("script").remove end # Read STYLE attributes and perform folding doc.search("*[@style]").each do |el| style = el.attributes['style'].to_s declarations = [] style.scan(/\[SPEC\=([\d]+)\[(.[^\]\]]*)\]\]/).each do |declaration| rs = CssParser::RuleSet.new(nil, declaration[1].to_s, declaration[0].to_i) declarations << rs end # Perform style folding merged = CssParser.merge(declarations) merged.expand_shorthand! # Duplicate CSS attributes as HTML attributes if Premailer::RELATED_ATTRIBUTES.has_key?(el.name) && @options[:css_to_attributes] Premailer::RELATED_ATTRIBUTES[el.name].each do |css_att, html_att| el[html_att] = merged[css_att].gsub(/url\(['|"](.*)['|"]\)/, '\1').gsub(/;$|\s*!important/, '').strip if el[html_att].nil? and not merged[css_att].empty? merged.instance_variable_get("@declarations").tap do |declarations| declarations.delete(css_att) end end end # Collapse multiple rules into one as much as possible. merged.create_shorthand! if @options[:create_shorthands] # write the inline STYLE attribute # split by ';' but ignore those in brackets attributes = Premailer.escape_string(merged.declarations_to_s).split(/;(?![^(]*\))/).map(&:strip) attributes = attributes.map { |attr| [attr.split(':').first, attr] }.sort_by { |pair| pair.first }.map { |pair| pair[1] } el['style'] = attributes.join('; ') + ";" end doc = write_unmergable_css_rules(doc, @unmergable_rules) if @options[:remove_classes] or @options[:remove_comments] doc.traverse do |el| if el.comment? and @options[:remove_comments] el.remove elsif el.element? el.remove_attribute('class') if @options[:remove_classes] end end end if @options[:remove_ids] # find all anchor's targets and hash them targets = [] doc.search("a[@href^='#']").each do |el| target = el.get_attribute('href')[1..-1] targets << target el.set_attribute('href', "#" + Digest::MD5.hexdigest(target)) end # hash ids that are links target, delete others doc.search("*[@id]").each do |el| id = el.get_attribute('id') if targets.include?(id) el.set_attribute('id', Digest::MD5.hexdigest(id)) else el.remove_attribute('id') end end end if @options[:reset_contenteditable] doc.search('*[@contenteditable]').each do |el| el.remove_attribute('contenteditable') end end @processed_doc = doc if is_xhtml? # we don't want to encode carriage returns @processed_doc.to_xhtml(:encoding => @options[:output_encoding]).gsub(/&\#(xD|13);/i, "\r") else @processed_doc.to_html(:encoding => @options[:output_encoding]) end end # Create a style element with un-mergable rules (e.g. :hover) # and write it into the body. # # doc is an Nokogiri document and unmergable_css_rules is a Css::RuleSet. # # @return [::Nokogiri::XML] a document. def write_unmergable_css_rules(doc, unmergable_rules) # :nodoc: styles = unmergable_rules.to_s unless styles.empty? style_tag = "" unless (body = doc.search('body')).empty? if doc.at_css('body').children && !doc.at_css('body').children.empty? doc.at_css('body').children.before(::Nokogiri::XML.fragment(style_tag)) else doc.at_css('body').add_child(::Nokogiri::XML.fragment(style_tag)) end else doc.inner_html = style_tag += doc.inner_html end end doc end # Converts the HTML document to a format suitable for plain-text e-mail. # # If present, uses the element as its base; otherwise uses the whole document. # # @return [String] a plain text. def to_plain_text html_src = '' begin html_src = @doc.at("body").inner_html rescue; end html_src = @doc.to_html unless html_src and not html_src.empty? convert_to_text(html_src, @options[:line_length], @html_encoding) end # Gets the original HTML as a string. # @return [String] HTML. def to_s if is_xhtml? @doc.to_xhtml(:encoding => nil) else @doc.to_html(:encoding => nil) end end # Load the HTML file and convert it into an Nokogiri document. # # @return [::Nokogiri::XML] a document. def load_html(input) # :nodoc: thing = nil # TODO: duplicate options if @options[:with_html_string] or @options[:inline] or input.respond_to?(:read) thing = input elsif @is_local_file @base_dir = File.dirname(input) thing = File.open(input, 'r') else thing = open(input) end if thing.respond_to?(:read) thing = thing.read end return nil unless thing doc = nil # Handle HTML entities if @options[:replace_html_entities] == true and thing.is_a?(String) HTML_ENTITIES.map do |entity, replacement| thing.gsub! entity, replacement end end # Default encoding is ASCII-8BIT (binary) per http://groups.google.com/group/nokogiri-talk/msg/0b81ef0dc180dc74 # However, we really don't want to hardcode this. ASCII-8BIT should be the default, but not the only option. if thing.is_a?(String) and RUBY_VERSION =~ /1.9/ thing = thing.force_encoding(@options[:input_encoding]).encode! doc = ::Nokogiri::HTML(thing, nil, @options[:input_encoding]) { |c| c.recover } else default_encoding = RUBY_PLATFORM == 'java' ? nil : 'BINARY' doc = ::Nokogiri::HTML(thing, nil, @options[:input_encoding] || default_encoding) { |c| c.recover } end # Fix for removing any CDATA tags from both style and script tags inserted per # https://github.com/sparklemotion/nokogiri/issues/311 and # https://github.com/premailer/premailer/issues/199 %w(style script).each do |tag| doc.search(tag).children.each do |child| child.swap(child.text()) if child.cdata? end end doc end end end end ================================================ FILE: lib/premailer/adapter/nokogumbo.rb ================================================ require 'nokogumbo' class Premailer module Adapter # Nokogiri adapter module Nokogumbo # Merge CSS into the HTML document. # # @return [String] an HTML. def to_inline_css doc = @processed_doc @unmergable_rules = CssParser::Parser.new # Give all styles already in style attributes a specificity of 1000 # per http://www.w3.org/TR/CSS21/cascade.html#specificity doc.search("*[@style]").each do |el| el['style'] = '[SPEC=1000[' + el.attributes['style'] + ']]' end # Iterate through the rules and merge them into the HTML @css_parser.each_selector(:all) do |selector, declaration, specificity, media_types| # Save un-mergable rules separately selector.gsub!(/:link([\s]*)+/i) { |m| $1 } # Convert element names to lower case selector.gsub!(/([\s]|^)([\w]+)/) { |m| $1.to_s + $2.to_s.downcase } if Premailer.is_media_query?(media_types) || selector =~ Premailer::RE_UNMERGABLE_SELECTORS @unmergable_rules.add_rule_set!(CssParser::RuleSet.new(selector, declaration), media_types) unless @options[:preserve_styles] else begin if selector =~ Premailer::RE_RESET_SELECTORS # this is in place to preserve the MailChimp CSS reset: http://github.com/mailchimp/Email-Blueprints/ # however, this doesn't mean for testing pur @unmergable_rules.add_rule_set!(CssParser::RuleSet.new(selector, declaration)) unless !@options[:preserve_reset] end # Change single ID CSS selectors into xpath so that we can match more # than one element. Added to work around dodgy generated code. selector.gsub!(/\A\#([\w_\-]+)\Z/, '*[@id=\1]') doc.search(selector).each do |el| if el.elem? and (el.name != 'head' and el.parent.name != 'head') # Add a style attribute or append to the existing one block = "[SPEC=#{specificity}[#{declaration}]]" el['style'] = (el.attributes['style'].to_s ||= '') + ' ' + block end end rescue ::Nokogiri::SyntaxError, RuntimeError, ArgumentError $stderr.puts "CSS syntax error with selector: #{selector}" if @options[:verbose] next end end end # Remove script tags if @options[:remove_scripts] doc.search("script").remove end # Read STYLE attributes and perform folding doc.search("*[@style]").each do |el| style = el.attributes['style'].to_s declarations = [] style.scan(/\[SPEC\=([\d]+)\[(.[^\]\]]*)\]\]/).each do |declaration| rs = CssParser::RuleSet.new(nil, declaration[1].to_s, declaration[0].to_i) declarations << rs end # Perform style folding merged = CssParser.merge(declarations) merged.expand_shorthand! # Duplicate CSS attributes as HTML attributes if Premailer::RELATED_ATTRIBUTES.has_key?(el.name) && @options[:css_to_attributes] Premailer::RELATED_ATTRIBUTES[el.name].each do |css_att, html_att| el[html_att] = merged[css_att].gsub(/url\(['|"](.*)['|"]\)/, '\1').gsub(/;$|\s*!important/, '').strip if el[html_att].nil? and not merged[css_att].empty? merged.instance_variable_get("@declarations").tap do |declarations| declarations.delete(css_att) end end end # Collapse multiple rules into one as much as possible. merged.create_shorthand! if @options[:create_shorthands] # write the inline STYLE attribute attributes = Premailer.escape_string(merged.declarations_to_s).split(';').map(&:strip) attributes = attributes.map { |attr| [attr.split(':').first, attr] }.sort_by { |pair| pair.first }.map { |pair| pair[1] } el['style'] = attributes.join('; ') + ";" end doc = write_unmergable_css_rules(doc, @unmergable_rules) if @options[:remove_classes] or @options[:remove_comments] doc.traverse do |el| if el.comment? and @options[:remove_comments] el.remove elsif el.element? el.remove_attribute('class') if @options[:remove_classes] end end end if @options[:remove_ids] # find all anchor's targets and hash them targets = [] doc.search("a[@href^='#']").each do |el| target = el.get_attribute('href')[1..-1] targets << target el.set_attribute('href', "#" + Digest::MD5.hexdigest(target)) end # hash ids that are links target, delete others doc.search("*[@id]").each do |el| id = el.get_attribute('id') if targets.include?(id) el.set_attribute('id', Digest::MD5.hexdigest(id)) else el.remove_attribute('id') end end end if @options[:reset_contenteditable] doc.search('*[@contenteditable]').each do |el| el.remove_attribute('contenteditable') end end @processed_doc = doc if is_xhtml? # we don't want to encode carriage returns @processed_doc.to_xhtml(:encoding => @options[:output_encoding]).gsub(/&\#(xD|13);/i, "\r") else @processed_doc.to_html(:encoding => @options[:output_encoding]) end end # Create a style element with un-mergable rules (e.g. :hover) # and write it into the body. # # doc is an Nokogiri document and unmergable_css_rules is a Css::RuleSet. # # @return [::Nokogiri::XML] a document. def write_unmergable_css_rules(doc, unmergable_rules) # :nodoc: styles = unmergable_rules.to_s unless styles.empty? style_tag = "" unless (body = doc.search('body')).empty? if doc.at_css('body').children && !doc.at_css('body').children.empty? doc.at_css('body').children.before(::Nokogiri::XML.fragment(style_tag)) else doc.at_css('body').add_child(::Nokogiri::XML.fragment(style_tag)) end else doc.inner_html = style_tag += doc.inner_html end end doc end # Converts the HTML document to a format suitable for plain-text e-mail. # # If present, uses the element as its base; otherwise uses the whole document. # # @return [String] a plain text. def to_plain_text html_src = '' begin html_src = @doc.at("body").inner_html rescue; end html_src = @doc.to_html unless html_src and not html_src.empty? convert_to_text(html_src, @options[:line_length], @html_encoding) end # Gets the original HTML as a string. # @return [String] HTML. def to_s if is_xhtml? @doc.to_xhtml(:encoding => nil) else @doc.to_html(:encoding => nil) end end # Load the HTML file and convert it into an Nokogiri document. # # @return [::Nokogiri::XML] a document. def load_html(input) # :nodoc: thing = nil # TODO: duplicate options if @options[:with_html_string] or @options[:inline] or input.respond_to?(:read) thing = input elsif @is_local_file @base_dir = File.dirname(input) thing = File.open(input, 'r') else thing = open(input) end if thing.respond_to?(:read) thing = thing.read end return nil unless thing doc = nil # Handle HTML entities if @options[:replace_html_entities] == true and thing.is_a?(String) HTML_ENTITIES.map do |entity, replacement| thing.gsub! entity, replacement end end # Default encoding is ASCII-8BIT (binary) per http://groups.google.com/group/nokogiri-talk/msg/0b81ef0dc180dc74 # However, we really don't want to hardcode this. ASCII-8BIT should be the default, but not the only option. if thing.is_a?(String) and RUBY_VERSION =~ /1.9/ thing = thing.force_encoding(@options[:input_encoding]).encode! doc = ::Nokogiri::HTML5(thing) else default_encoding = RUBY_PLATFORM == 'java' ? nil : 'BINARY' doc = ::Nokogiri::HTML5(thing) end # Fix for removing any CDATA tags from both style and script tags inserted per # https://github.com/sparklemotion/nokogiri/issues/311 and # https://github.com/premailer/premailer/issues/199 %w(style script).each do |tag| doc.search(tag).children.each do |child| child.swap(child.text()) if child.cdata? end end doc end end end end ================================================ FILE: lib/premailer/adapter.rb ================================================ class Premailer # Manages the adapter classes. Currently supports: # # * nokogiri # * hpricot module Adapter autoload :Hpricot, 'premailer/adapter/hpricot' autoload :Nokogiri, 'premailer/adapter/nokogiri' autoload :Nokogumbo, 'premailer/adapter/nokogumbo' # adapter to required file mapping. REQUIREMENT_MAP = [ ["hpricot", :hpricot], ["nokogiri", :nokogiri], ["nokogumbi", :nokogumbo], ] # Returns the adapter to use. def self.use return @use if @use self.use = self.default @use end # The default adapter based on what you currently have loaded and # installed. First checks to see if any adapters are already loaded, # then checks to see which are installed if none are loaded. # @raise [RuntimeError] unless suitable adapter found. def self.default return :hpricot if defined?(::Hpricot) return :nokogiri if defined?(::Nokogiri) return :nokogumbo if defined?(::Nokogumbo) REQUIREMENT_MAP.each do |(library, adapter)| begin require library return adapter rescue LoadError next end end raise RuntimeError.new("No suitable adapter for Premailer was found, please install hpricot or nokogiri") end # Sets the adapter to use. # @raise [ArgumentError] unless the adapter exists. def self.use=(new_adapter) @use = find(new_adapter) end # Returns an adapter. # @raise [ArgumentError] unless the adapter exists. def self.find(adapter) return adapter if adapter.is_a?(Module) Premailer::Adapter.const_get("#{adapter.to_s.split('_').map{|s| s.capitalize}.join('')}") rescue NameError raise ArgumentError, "Invalid adapter: #{adapter}" end end end ================================================ FILE: lib/premailer/executor.rb ================================================ require 'optparse' require 'premailer' # defaults options = { :base_url => nil, :link_query_string => nil, :remove_classes => false, :verbose => false, :line_length => 65 } mode = :html opts = OptionParser.new do |opts| opts.banner = "Improve the rendering of HTML emails by making CSS inline among other things. Takes a path to a local file, a URL or a pipe as input.\n\n" opts.define_head "Usage: premailer [options]" opts.separator "" opts.separator "Examples:" opts.separator " premailer http://example.com/ > out.html" opts.separator " premailer http://example.com/ --mode txt > out.txt" opts.separator " cat input.html | premailer -q src=email > out.html" opts.separator " premailer ./public/index.html" opts.separator "" opts.separator "Options:" opts.on("--mode MODE", [:html, :txt], "Output: html or txt") do |v| mode = v end opts.on("-b", "--base-url STRING", String, "Base URL, useful for local files") do |v| options[:base_url] = v end opts.on("-q", "--query-string STRING", String, "Query string to append to links") do |v| options[:link_query_string] = v end opts.on("--css FILE,FILE", Array, "Additional CSS stylesheets") do |v| options[:css] = v end opts.on("-r", "--remove-classes", "Remove HTML classes") do |v| options[:remove_classes] = v end opts.on("-j", "--remove-scripts", "Remove content END_HTML [:nokogiri, :hpricot].each do |adapter| premailer = Premailer.new(html, :with_html_string => true, :remove_scripts => true, :adapter => adapter) premailer.to_inline_css assert_equal 0, premailer.processed_doc.search('script').length end [:nokogiri, :hpricot].each do |adapter| premailer = Premailer.new(html, :with_html_string => true, :remove_scripts => false, :adapter => adapter) premailer.to_inline_css assert_equal 1, premailer.processed_doc.search('script').length end end def test_strip_important_from_attributes html = <
red
END_HTML [:nokogiri, :hpricot].each do |adapter| premailer = Premailer.new(html, :with_html_string => true, :adapter => adapter) assert_match 'bgcolor="#FF0000"', premailer.to_inline_css end end def test_scripts_with_nokogiri html = < END_HTML premailer = Premailer.new(html, :with_html_string => true, :remove_scripts => false, :adapter => :nokogiri) premailer.to_inline_css assert !premailer.processed_doc.css('script[type="application/ld+json"]').first.children.first.cdata? end def test_style_without_data_in_content html = < END_HTML [:nokogiri, :hpricot].each do |adapter| premailer = Premailer.new(html, :with_html_string => true, :adapter => adapter) assert_match 'content: url(good.png)', premailer.to_inline_css end end def test_style_with_data_in_content html = < END_HTML [:nokogiri, :hpricot].each do |adapter| premailer = Premailer.new(html, :with_html_string => true, :adapter => adapter) assert_match 'content: url(data:image/png;base64,LOTSOFSTUFF)', premailer.to_inline_css end end end ================================================ FILE: test/test_premailer.rb ================================================ # -*- encoding: UTF-8 -*- require File.expand_path(File.dirname(__FILE__)) + '/helper' class TestPremailer < Premailer::TestCase def test_special_characters_nokogiri html = '

cédille cé & garçon garçon à à   & ©

' premailer = Premailer.new(html, :with_html_string => true, :adapter => :nokogiri) premailer.to_inline_css assert_equal 'cédille cé & garçon garçon à à   & ©', premailer.processed_doc.at('p').inner_html end def test_special_characters_nokogiri_remote remote_setup('chars.html', :adapter => :nokogiri) @premailer.to_inline_css assert_equal 'cédille cé & garçon garçon à à   & ©', @premailer.processed_doc.at('p').inner_html end #def test_cyrillic_nokogiri_remote # if RUBY_VERSION =~ /1.9/ # remote_setup('iso-8859-5.html', :adapter => :nokogiri) #, :encoding => 'iso-8859-5') # @premailer.to_inline_css # assert_equal Encoding.find('ISO-8859-5'), @premailer.processed_doc.at('p').inner_html.encoding # end #end # TODO: this passes when run from rake but not when run from: # ruby -Itest test/test_premailer.rb -n test_special_characters_hpricot def test_special_characters_hpricot html = '

cédille cé & garçon garçon à à   &

' premailer = Premailer.new(html, :with_html_string => true, :adapter => :hpricot) premailer.to_inline_css assert_equal 'cédille cé & garçon garçon à à   &', premailer.processed_doc.at('p').inner_html end def test_detecting_html [:nokogiri, :hpricot].each do |adapter| remote_setup('base.html', :adapter => adapter) assert !@premailer.is_xhtml? end end def test_detecting_xhtml [:nokogiri, :hpricot].each do |adapter| remote_setup('xhtml.html', :adapter => adapter) assert @premailer.is_xhtml? end end def test_self_closing_xhtml_tags [:nokogiri, :hpricot].each do |adapter| remote_setup('xhtml.html', :adapter => adapter) assert_match //, @premailer.to_s assert_match //, @premailer.to_inline_css end end def test_non_self_closing_html_tags [:nokogiri, :hpricot].each do |adapter| remote_setup('html4.html', :adapter => adapter) assert_match /
/, @premailer.to_s assert_match /
/, @premailer.to_inline_css end end def test_mailtos_with_query_strings html = < Test END_HTML qs = 'testing=123' [:nokogiri, :hpricot].each do |adapter| premailer = Premailer.new(html, :with_html_string => true, :link_query_string => qs, :adapter => adapter) premailer.to_inline_css refute_match /testing=123/, premailer.processed_doc.search('a').first.attributes['href'].to_s end end def test_escaping_strings local_setup str = %q{url("/images/test.png");} assert_equal("url(\'/images/test.png\');", Premailer.escape_string(str)) end def test_preserving_ignored_style_elements [:nokogiri, :hpricot].each do |adapter| local_setup('ignore.html', :adapter => adapter) assert_nil @doc.at('h1')['style'] end end def test_preserving_ignored_link_elements [:nokogiri, :hpricot].each do |adapter| local_setup('ignore.html', :adapter => adapter) assert_nil @doc.at('body')['style'] end end def test_importing_local_css # , :hpricot [:nokogiri].each do |adapter| local_setup('base.html', :adapter => adapter) # noimport.css (print stylesheet) sets body { background } to red refute_match /red/, @doc.at('body').attributes['style'].to_s # import.css sets .hide to { display: none } assert_match /display: none/, @doc.at('#hide01').attributes['style'].to_s end end def test_css_to_attributes [:nokogiri, :hpricot].each do |adapter| html = '' premailer = Premailer.new(html, {:with_html_string => true, :adapter => adapter, :css_to_attributes => true}) premailer.to_inline_css assert_equal ';', premailer.processed_doc.search('td').first.attributes['style'].to_s assert_equal '#FFF', premailer.processed_doc.search('td').first.attributes['bgcolor'].to_s end end def test_avoid_changing_css_to_attributes [:nokogiri, :hpricot].each do |adapter| html = '' premailer = Premailer.new(html, {:with_html_string => true, :adapter => adapter, :css_to_attributes => false}) premailer.to_inline_css assert_match /background: #FFF/, premailer.processed_doc.search('td').first.attributes['style'].to_s end end def test_importing_remote_css [:nokogiri, :hpricot].each do |adapter| remote_setup('base.html', :adapter => adapter) # noimport.css (print stylesheet) sets body { background } to red refute_match /red/, @doc.at('body')['style'] # import.css sets .hide to { display: none } assert_match /display: none/, @doc.at('#hide01')['style'] end end def test_importing_css_as_string files_base = File.expand_path(File.dirname(__FILE__)) + '/files/' css_string = IO.read(File.join(files_base, 'import.css')) [:nokogiri, :hpricot].each do |adapter| premailer = Premailer.new(File.join(files_base, 'no_css.html'), {:css_string => css_string, :adapter => adapter}) premailer.to_inline_css @doc = premailer.processed_doc # import.css sets .hide to { display: none } assert_match /display: none/, @doc.at('#hide01')['style'] end end def test_local_remote_check assert Premailer.local_data?( StringIO.new('a') ) assert Premailer.local_data?( '/path/' ) assert !Premailer.local_data?( 'http://example.com/path/' ) # the old way is deprecated but should still work premailer = Premailer.new( StringIO.new('a') ) silence_stderr do assert premailer.local_uri?( '/path/' ) end end def test_initialize_can_accept_io_object [:nokogiri, :hpricot].each do |adapter| io = StringIO.new('hi mom') premailer = Premailer.new(io, :adapter => adapter) assert_match /hi mom/, premailer.to_inline_css end end def test_initialize_can_accept_html_string [:nokogiri, :hpricot].each do |adapter| premailer = Premailer.new('

test

', :with_html_string => true, :adapter => adapter) assert_match /test/, premailer.to_inline_css end end def test_initialize_no_escape_attributes_option html = < Google Link END_HTML [:nokogiri, :hpricot].each do |adapter| pm = Premailer.new(html, :with_html_string => true, :adapter => adapter, :escape_url_attributes => false) pm.to_inline_css doc = pm.processed_doc assert_equal doc.at('#google')['href'], 'http://google.com' assert_equal doc.at('#noescape')['href'], '{{link_url}}' end end def test_remove_ids html = <

Test

Test

END_HTML [:nokogiri, :hpricot].each do |adapter| pm = Premailer.new(html, :with_html_string => true, :remove_ids => true, :adapter => adapter) pm.to_inline_css doc = pm.processed_doc assert_nil doc.at('#remove') assert_nil doc.at('#keep') hashed_id = doc.at('a')['href'][1..-1] refute_nil doc.at("\##{hashed_id}") end end def test_reset_contenteditable html = <<-___
Test
___ [:nokogiri, :hpricot].each do |adapter| pm = Premailer.new(html, :with_html_string => true, :reset_contenteditable => true, :adapter => adapter) pm.to_inline_css doc = pm.processed_doc assert_nil doc.at('#editable')['contenteditable'], "#{adapter}: contenteditable attribute not removed" end end def test_carriage_returns_as_entities html = <<-html \n\r

test

\n\r

test

html [:nokogiri, :hpricot].each do |adapter| pm = Premailer.new(html, :with_html_string => true, :adapter => adapter) assert_match /\r/, pm.to_inline_css end end def test_advanced_selectors remote_setup('base.html', :adapter => :nokogiri) assert_match /italic/, @doc.at('h2 + h3')['style'] assert_match /italic/, @doc.at('p[attr~=quote]')['style'] assert_match /italic/, @doc.at('ul li:first-of-type')['style'] remote_setup('base.html', :adapter => :hpricot) assert_match /italic/, @doc.at('p[@attr~="quote"]')['style'] assert_match /italic/, @doc.at('ul li:first-of-type')['style'] end def test_premailer_related_attributes html = <
Test
END_HTML [:nokogiri, :hpricot].each do |adapter| pm = Premailer.new(html, :with_html_string => true, :adapter => adapter) pm.to_inline_css doc = pm.processed_doc assert_equal '500', doc.at('table')['width'] assert_equal '20', doc.at('td')['height'] end end def test_include_link_tags_option local_setup('base.html', :adapter => :nokogiri, :include_link_tags => true) assert_match /1\.231/, @doc.at('body').attributes['style'].to_s assert_match /display: none/, @doc.at('.hide').attributes['style'].to_s local_setup('base.html', :adapter => :nokogiri, :include_link_tags => false) refute_match /1\.231/, @doc.at('body').attributes['style'].to_s assert_match /display: none/, @doc.at('.hide').attributes['style'].to_s end def test_include_style_tags_option local_setup('base.html', :adapter => :nokogiri, :include_style_tags => true) assert_match /1\.231/, @doc.at('body').attributes['style'].to_s assert_match /display: block/, @doc.at('#iphone').attributes['style'].to_s local_setup('base.html', :adapter => :nokogiri, :include_style_tags => false) assert_match /1\.231/, @doc.at('body').attributes['style'].to_s refute_match /display: block/, @doc.at('#iphone').attributes['style'].to_s end def test_input_encoding html_special_characters = "Ää, Öö, Üü" expected_html = "\n

" + html_special_characters + "

\n" pm = Premailer.new(html_special_characters, :with_html_string => true, :adapter => :nokogiri, :input_encoding => "UTF-8") assert_equal expected_html, pm.to_inline_css end # output_encoding option should return HTML Entities when set to US-ASCII def test_output_encoding html_special_characters = "©" html_entities_characters = "©" expected_html = "\n

" + html_entities_characters + "

\n" pm = Premailer.new(html_special_characters, :output_encoding => "US-ASCII", :with_html_string => true, :adapter => :nokogiri, :input_encoding => "UTF-8"); assert_equal expected_html, pm.to_inline_css end def test_meta_encoding_downcase meta_encoding = '' expected_html = Regexp.new(Regexp.escape(''), Regexp::IGNORECASE) pm = Premailer.new(meta_encoding, :with_html_string => true, :adapter => :nokogiri, :input_encoding => "utf-8") assert_match expected_html, pm.to_inline_css end def test_meta_encoding_upcase meta_encoding = '' expected_html = Regexp.new(Regexp.escape(''), Regexp::IGNORECASE) pm = Premailer.new(meta_encoding, :with_html_string => true, :adapter => :nokogiri, :input_encoding => "UTF-8") assert_match expected_html, pm.to_inline_css end def test_htmlentities html_entities = "’" expected_html = "\n

'

\n" pm = Premailer.new(html_entities, :with_html_string => true, :adapter => :nokogiri, :replace_html_entities => true) assert_equal expected_html, pm.to_inline_css end # If a line other than the first line in the html string begins with a URI # Premailer should not identify the html string as a URI. Otherwise the following # exception would be raised: ActionView::Template::Error: bad URI(is not URI?) def test_line_starting_with_uri_in_html_with_linked_css files_base = File.expand_path(File.dirname(__FILE__)) + '/files/' html_string = IO.read(File.join(files_base, 'html_with_uri.html')) premailer = Premailer.new(html_string, :with_html_string => true) premailer.to_inline_css end def test_empty_html_nokogiri html = "" css = "a:hover {color:red;}" pm = Premailer.new(html, :with_html_string => true, :css_string => css, :adapter => :nokogiri) pm.to_inline_css end def silence_stderr(&block) orig_stderr = $stderr $stderr = File.open(File::NULL, 'w') block.call ensure $stderr = orig_stderr end end ================================================ FILE: test/test_warnings.rb ================================================ # encoding: UTF-8 require File.expand_path(File.dirname(__FILE__)) + '/helper' class TestWarnings < Premailer::TestCase def test_element_warnings html = <
Test
END_HTML [:nokogiri, :hpricot].each do |adapter| warnings = get_warnings(html, adapter) assert_equal 2, warnings.length assert warnings.any? { |w| w[:message] == 'form HTML element'} assert warnings.any? { |w| w[:message] == 'link HTML element'} end end def test_css_warnings html = <
Test
END_HTML [:nokogiri, :hpricot].each do |adapter| warnings = get_warnings(html, adapter) assert_equal 2, warnings.length assert warnings.any? { |w| w[:message] == 'height CSS property'} assert warnings.any? { |w| w[:message] == 'margin CSS property'} end end def test_css_aliased_warnings html = <
Test
END_HTML [:nokogiri, :hpricot].each do |adapter| warnings = get_warnings(html, adapter) assert_equal 1, warnings.length assert warnings.any? { |w| w[:message] == 'margin-top CSS property'} end end def test_attribute_warnings html = < END_HTML [:nokogiri, :hpricot].each do |adapter| warnings = get_warnings(html, adapter) assert_equal 1, warnings.length assert warnings.any? { |w| w[:message] == 'ismap HTML attribute'} end end def test_warn_level html = <
Test
END_HTML [:nokogiri, :hpricot].each do |adapter| warnings = get_warnings(html, adapter, Premailer::Warnings::SAFE) assert_equal 2, warnings.length end [:nokogiri, :hpricot].each do |adapter| warnings = get_warnings(html, adapter, Premailer::Warnings::POOR) assert_equal 1, warnings.length end end protected def get_warnings(html, adapter = :nokogiri, warn_level = Premailer::Warnings::SAFE) pm = Premailer.new(html, {:adpater => adapter, :with_html_string => true, :warn_level => warn_level}) pm.to_inline_css pm.check_client_support end end