premailer 1.8.7 → 1.9.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: ceccb9e67d075de22a76d50df38d99481a6811bf
4
- data.tar.gz: 475cc5974d20a0b8b69bef467d10de3edff7d821
3
+ metadata.gz: 2e8e7d38035296170890e416aa7b33bb1aef052b
4
+ data.tar.gz: 4ee232c94b2585448385d2e8e8b8884380ada987
5
5
  SHA512:
6
- metadata.gz: 2591ab12195ae73b9909b945131cdf06382917afde4e4270dc496b937eac87c98cea081c825209a0e4cfd40f8b2202f1953f980d8203cf90afcadec80b139c17
7
- data.tar.gz: e34eda9604977da572ef0f712c710547e9e7d965370404a2b1a233a2dd04760a7461c70acf4b40008b72824fe2a38f4cb18bd36d49da794bd61bd41a09096721
6
+ metadata.gz: a6a5daedd7752a35cf677cbf62d58697809cc496f44372247f7a080395a13756e9a9a1e633c44ba44faeff55f83c118454cb146c7860108cd5d792dda0d82ab2
7
+ data.tar.gz: d4d53382db2412c1c8e4e022adc114828fd46e79c8fd6f8903089c34a2ffed0b8799252792f0fdf1eca3e0d7844bd6bffa8feccc8bf5b6e6294aec9b9ccda0c1
data/README.md CHANGED
@@ -36,16 +36,17 @@ require 'premailer'
36
36
 
37
37
  premailer = Premailer.new('http://example.com/myfile.html', :warn_level => Premailer::Warnings::SAFE)
38
38
 
39
- # Write the HTML output
40
- File.open("output.html", "w") do |fout|
41
- fout.puts premailer.to_inline_css
42
- end
43
-
44
39
  # Write the plain-text output
40
+ # This must come before to_inline_css (https://github.com/premailer/premailer/issues/201)
45
41
  File.open("output.txt", "w") do |fout|
46
42
  fout.puts premailer.to_plain_text
47
43
  end
48
44
 
45
+ # Write the HTML output
46
+ File.open("output.html", "w") do |fout|
47
+ fout.puts premailer.to_inline_css
48
+ end
49
+
49
50
  # Output any CSS warnings
50
51
  premailer.warnings.each do |w|
51
52
  puts "#{w[:message]} (#{w[:level]}) may not render properly in #{w[:clients]}"
@@ -4,17 +4,20 @@ class Premailer
4
4
  # Manages the adapter classes. Currently supports:
5
5
  #
6
6
  # * nokogiri
7
+ # * nokogiri_fast
7
8
  # * nokogumbo
8
9
  # * hpricot
9
10
  module Adapter
10
11
 
11
12
  autoload :Hpricot, 'premailer/adapter/hpricot'
12
13
  autoload :Nokogiri, 'premailer/adapter/nokogiri'
14
+ autoload :NokogiriFast, 'premailer/adapter/nokogiri_fast'
13
15
  autoload :Nokogumbo, 'premailer/adapter/nokogumbo'
14
16
 
15
17
  # adapter to required file mapping.
16
18
  REQUIREMENT_MAP = [
17
19
  ["nokogiri", :nokogiri],
20
+ ["nokogiri", :nokogiri_fast],
18
21
  ["nokogumbo", :nokogumbo],
19
22
  ["hpricot", :hpricot],
20
23
  ]
@@ -32,6 +35,7 @@ class Premailer
32
35
  # @raise [RuntimeError] unless suitable adapter found.
33
36
  def self.default
34
37
  return :nokogiri if defined?(::Nokogiri)
38
+ return :nokogiri_fast if defined?(::NokogiriFast)
35
39
  return :nokogumbo if defined?(::Nokogumbo)
36
40
  return :hpricot if defined?(::Hpricot)
37
41
 
@@ -0,0 +1,354 @@
1
+ require 'nokogiri'
2
+
3
+ class Premailer
4
+ module Adapter
5
+ # NokogiriFast adapter
6
+ module NokogiriFast
7
+
8
+ # Merge CSS into the HTML document.
9
+ #
10
+ # @return [String] an HTML.
11
+ def to_inline_css
12
+ doc = @processed_doc
13
+ @unmergable_rules = CssParser::Parser.new
14
+
15
+ # Give all styles already in style attributes a specificity of 1000
16
+ # per http://www.w3.org/TR/CSS21/cascade.html#specificity
17
+ doc.search("*[@style]").each do |el|
18
+ el['style'] = '[SPEC=1000[' + el.attributes['style'] + ']]'
19
+ end
20
+
21
+ # Create an index for nodes by tag name/id/class
22
+ # Also precompute the map of nodes to descendants
23
+ index, all_nodes, descendants = make_index(doc)
24
+
25
+ # Iterate through the rules and merge them into the HTML
26
+ @css_parser.each_selector(:all) do |selector, declaration, specificity, media_types|
27
+
28
+ # Save un-mergable rules separately
29
+ selector.gsub!(/:link([\s]*)+/i) { |m| $1 }
30
+
31
+ # Convert element names to lower case
32
+ selector.gsub!(/([\s]|^)([\w]+)/) { |m| $1.to_s + $2.to_s.downcase }
33
+
34
+ if Premailer.is_media_query?(media_types) || selector =~ Premailer::RE_UNMERGABLE_SELECTORS
35
+ @unmergable_rules.add_rule_set!(CssParser::RuleSet.new(selector, declaration), media_types) unless @options[:preserve_styles]
36
+ else
37
+ begin
38
+ if selector =~ Premailer::RE_RESET_SELECTORS
39
+ # this is in place to preserve the MailChimp CSS reset: http://github.com/mailchimp/Email-Blueprints/
40
+ # however, this doesn't mean for testing pur
41
+ @unmergable_rules.add_rule_set!(CssParser::RuleSet.new(selector, declaration)) unless !@options[:preserve_reset]
42
+ end
43
+
44
+ # Try the new index based technique. If not supported, fall back to the old brute force one.
45
+ nodes = match_selector(index, all_nodes, descendants, selector) || doc.search(selector)
46
+ nodes.each do |el|
47
+ if el.elem? and (el.name != 'head' and el.parent.name != 'head')
48
+ # Add a style attribute or append to the existing one
49
+ block = "[SPEC=#{specificity}[#{declaration}]]"
50
+ el['style'] = (el.attributes['style'].to_s ||= '') + ' ' + block
51
+ end
52
+ end
53
+ rescue ::Nokogiri::SyntaxError, RuntimeError, ArgumentError
54
+ $stderr.puts "CSS syntax error with selector: #{selector}" if @options[:verbose]
55
+ next
56
+ end
57
+ end
58
+ end
59
+
60
+ # Remove script tags
61
+ doc.search("script").remove if @options[:remove_scripts]
62
+
63
+ # Read STYLE attributes and perform folding
64
+ doc.search("*[@style]").each do |el|
65
+ style = el.attributes['style'].to_s
66
+
67
+ declarations = []
68
+ style.scan(/\[SPEC\=([\d]+)\[(.[^\]\]]*)\]\]/).each do |declaration|
69
+ rs = CssParser::RuleSet.new(nil, declaration[1].to_s, declaration[0].to_i)
70
+ declarations << rs
71
+ end
72
+
73
+ # Perform style folding
74
+ merged = CssParser.merge(declarations)
75
+ merged.expand_shorthand!
76
+
77
+ # Duplicate CSS attributes as HTML attributes
78
+ if Premailer::RELATED_ATTRIBUTES.has_key?(el.name) && @options[:css_to_attributes]
79
+ Premailer::RELATED_ATTRIBUTES[el.name].each do |css_att, html_att|
80
+ el[html_att] = merged[css_att].gsub(/url\(['|"](.*)['|"]\)/, '\1').gsub(/;$|\s*!important/, '').strip if el[html_att].nil? and not merged[css_att].empty?
81
+ merged.instance_variable_get("@declarations").tap do |declarations|
82
+ declarations.delete(css_att)
83
+ end
84
+ end
85
+ end
86
+ # Collapse multiple rules into one as much as possible.
87
+ merged.create_shorthand! if @options[:create_shorthands]
88
+
89
+ # write the inline STYLE attribute
90
+ # split by ';' but ignore those in brackets
91
+ attributes = Premailer.escape_string(merged.declarations_to_s).split(/;(?![^(]*\))/).map(&:strip)
92
+ attributes = attributes.map { |attr| [attr.split(':').first, attr] }.sort_by { |pair| pair.first }.map { |pair| pair[1] }
93
+ el['style'] = attributes.join('; ') + ";"
94
+ end
95
+
96
+ doc = write_unmergable_css_rules(doc, @unmergable_rules)
97
+
98
+ if @options[:remove_classes] or @options[:remove_comments]
99
+ doc.traverse do |el|
100
+ if el.comment? and @options[:remove_comments]
101
+ el.remove
102
+ elsif el.element?
103
+ el.remove_attribute('class') if @options[:remove_classes]
104
+ end
105
+ end
106
+ end
107
+
108
+ if @options[:remove_ids]
109
+ # find all anchor's targets and hash them
110
+ targets = []
111
+ doc.search("a[@href^='#']").each do |el|
112
+ target = el.get_attribute('href')[1..-1]
113
+ targets << target
114
+ el.set_attribute('href', "#" + Digest::MD5.hexdigest(target))
115
+ end
116
+ # hash ids that are links target, delete others
117
+ doc.search("*[@id]").each do |el|
118
+ id = el.get_attribute('id')
119
+ if targets.include?(id)
120
+ el.set_attribute('id', Digest::MD5.hexdigest(id))
121
+ else
122
+ el.remove_attribute('id')
123
+ end
124
+ end
125
+ end
126
+
127
+ if @options[:reset_contenteditable]
128
+ doc.search('*[@contenteditable]').each do |el|
129
+ el.remove_attribute('contenteditable')
130
+ end
131
+ end
132
+
133
+ @processed_doc = doc
134
+ if is_xhtml?
135
+ # we don't want to encode carriage returns
136
+ @processed_doc.to_xhtml(:encoding => @options[:output_encoding]).gsub(/&\#(xD|13);/i, "\r")
137
+ else
138
+ @processed_doc.to_html(:encoding => @options[:output_encoding])
139
+ end
140
+ end
141
+
142
+ # Create a <tt>style</tt> element with un-mergable rules (e.g. <tt>:hover</tt>)
143
+ # and write it into the <tt>body</tt>.
144
+ #
145
+ # <tt>doc</tt> is an Nokogiri document and <tt>unmergable_css_rules</tt> is a Css::RuleSet.
146
+ #
147
+ # @return [::Nokogiri::XML] a document.
148
+ def write_unmergable_css_rules(doc, unmergable_rules) # :nodoc:
149
+ styles = unmergable_rules.to_s
150
+
151
+ unless styles.empty?
152
+ style_tag = "<style type=\"text/css\">\n#{styles}</style>"
153
+ unless (body = doc.search('body')).empty?
154
+ if doc.at_css('body').children && !doc.at_css('body').children.empty?
155
+ doc.at_css('body').children.before(::Nokogiri::XML.fragment(style_tag))
156
+ else
157
+ doc.at_css('body').add_child(::Nokogiri::XML.fragment(style_tag))
158
+ end
159
+ else
160
+ doc.inner_html = style_tag += doc.inner_html
161
+ end
162
+ end
163
+ doc
164
+ end
165
+
166
+
167
+ # Converts the HTML document to a format suitable for plain-text e-mail.
168
+ #
169
+ # If present, uses the <body> element as its base; otherwise uses the whole document.
170
+ #
171
+ # @return [String] a plain text.
172
+ def to_plain_text
173
+ html_src = ''
174
+ begin
175
+ html_src = @doc.at("body").inner_html
176
+ rescue;
177
+ end
178
+
179
+ html_src = @doc.to_html unless html_src and not html_src.empty?
180
+ convert_to_text(html_src, @options[:line_length], @html_encoding)
181
+ end
182
+
183
+ # Gets the original HTML as a string.
184
+ # @return [String] HTML.
185
+ def to_s
186
+ if is_xhtml?
187
+ @doc.to_xhtml(:encoding => nil)
188
+ else
189
+ @doc.to_html(:encoding => nil)
190
+ end
191
+ end
192
+
193
+ # Load the HTML file and convert it into an Nokogiri document.
194
+ #
195
+ # @return [::Nokogiri::XML] a document.
196
+ def load_html(input) # :nodoc:
197
+ thing = nil
198
+
199
+ # TODO: duplicate options
200
+ if @options[:with_html_string] or @options[:inline] or input.respond_to?(:read)
201
+ thing = input
202
+ elsif @is_local_file
203
+ @base_dir = File.dirname(input)
204
+ thing = File.open(input, 'r')
205
+ else
206
+ thing = open(input)
207
+ end
208
+
209
+ if thing.respond_to?(:read)
210
+ thing = thing.read
211
+ end
212
+
213
+ return nil unless thing
214
+ doc = nil
215
+
216
+ # Handle HTML entities
217
+ if @options[:replace_html_entities] == true and thing.is_a?(String)
218
+ HTML_ENTITIES.map do |entity, replacement|
219
+ thing.gsub! entity, replacement
220
+ end
221
+ end
222
+ # Default encoding is ASCII-8BIT (binary) per http://groups.google.com/group/nokogiri-talk/msg/0b81ef0dc180dc74
223
+ # However, we really don't want to hardcode this. ASCII-8BIT should be the default, but not the only option.
224
+ if thing.is_a?(String) and RUBY_VERSION =~ /1.9/
225
+ thing = thing.force_encoding(@options[:input_encoding]).encode!
226
+ doc = ::Nokogiri::HTML5(thing)
227
+ else
228
+ default_encoding = RUBY_PLATFORM == 'java' ? nil : 'BINARY'
229
+ doc = ::Nokogiri::HTML5(thing)
230
+ end
231
+
232
+ # Fix for removing any CDATA tags from both style and script tags inserted per
233
+ # https://github.com/sparklemotion/nokogiri/issues/311 and
234
+ # https://github.com/premailer/premailer/issues/199
235
+ %w(style script).each do |tag|
236
+ doc.search(tag).children.each do |child|
237
+ child.swap(child.text()) if child.cdata?
238
+ end
239
+ end
240
+
241
+ doc
242
+ end
243
+
244
+ private
245
+
246
+ # For very large documents, it is useful to trade off some memory for performance.
247
+ # We can build an index of the nodes so we can quickly select by id/class/tagname
248
+ # instead of search the tree again and again.
249
+ #
250
+ # @param page The Nokogiri HTML document to index.
251
+ # @return [index, set_of_all_nodes, descendants] The index is a hash from key to set of nodes.
252
+ # The "descendants" is a hash mapping a node to the set of its descendant nodes.
253
+ def make_index(page)
254
+ index = {} # Contains a map of tag/class/id names to set of nodes.
255
+ all_nodes = [] # A plain array of all nodes in the doc. The superset.
256
+ descendants = {} # Maps node -> set of descendants
257
+
258
+ page.traverse do |node|
259
+ all_nodes.push(node)
260
+
261
+ if node != page then
262
+ index_ancestry(page, node, node.parent, descendants)
263
+ end
264
+
265
+ # Index the node by tag name. This is the least selective
266
+ # of the three index types empirically.
267
+ index[node.name] = (index[node.name] || Set.new).add(node)
268
+
269
+ # Index the node by all class attributes it possesses.
270
+ # Classes are modestly selective. Usually more than tag names
271
+ # but less selective than ids.
272
+ if node.has_attribute?("class") then
273
+ node.get_attribute("class").split(/\s+/).each do |c|
274
+ c = '.' + c
275
+ index[c] = (index[c] || Set.new).add(node)
276
+ end
277
+ end
278
+
279
+ # Index the node by its "id" attribute if it has one.
280
+ # This is usually the most selective of the three.
281
+ if node.has_attribute?("id") then
282
+ id = '#' + node.get_attribute("id")
283
+ index[id] = (index[id] || Set.new).add(node)
284
+ end
285
+ end
286
+
287
+ # If an index key isn't there, then we should treat it as an empty set.
288
+ # This makes the index total and we don't need to special case presence.
289
+ # Note that the default value will never be modified. So we don't need
290
+ # default_proc.
291
+ index.default = Set.new
292
+ descendants.default = Set.new
293
+
294
+ return index, Set.new(all_nodes), descendants
295
+ end
296
+
297
+ # @param doc The top level document
298
+ # @param elem The element whose ancestry is to be captured
299
+ # @param parent the current parent in the process of capturing. Should be set to elem.parent for starters.
300
+ # @param descendants The running hash map of node -> set of nodes that maps descendants of a node.
301
+ # @return The descendants argument after updating it.
302
+ def index_ancestry(doc, elem, parent, descendants)
303
+ if parent then
304
+ descendants[parent] = (descendants[parent] || Set.new).add(elem)
305
+ if doc != parent then
306
+ index_ancestry(doc, elem, parent.parent, descendants)
307
+ end
308
+ end
309
+ descendants
310
+ end
311
+
312
+ # @param index An index hash returned by make_index
313
+ # @param base The base set of nodes within which the given spec is to be matched.
314
+ # @param intersection_selector A CSS intersection selector string of the form
315
+ # "hello.world" or "#blue.diamond". This should not contain spaces.
316
+ # @return Set of nodes matching the given spec that are present in the base set.
317
+ def narrow_down_nodes(index, base, intersection_selector)
318
+ intersection_selector.split(/(?=[.#])/).reduce(base) do |acc, sel|
319
+ acc = index[sel].intersection(acc)
320
+ acc
321
+ end
322
+ end
323
+
324
+ # @param index An index returned by make_index
325
+ # @param allNodes The set of all nodes in the DOM to search
326
+ # @param selector A simple CSS tree matching selector of the form "div.container p.item span"
327
+ # @return Set of matching nodes
328
+ #
329
+ # Note that fancy CSS selector syntax is not supported. Anything
330
+ # not matching the regex /^[-a-zA-Z0-9\s_.#]*$/ should not be passed.
331
+ # It will return nil when such a selector is passed, so you can take
332
+ # action on the falsity of the return value.
333
+ def match_selector(index, all_nodes, descendants, selector)
334
+ if /[^-a-zA-Z0-9_\s.#]/.match(selector) then
335
+ return nil
336
+ end
337
+
338
+ take_children = false
339
+ selector.split(/\s+/).reduce(all_nodes) do |base, spec|
340
+ desc = base
341
+ if take_children then
342
+ desc = Set.new
343
+ base.each do |n|
344
+ desc.merge(descendants[n])
345
+ end
346
+ else
347
+ take_children = true
348
+ end
349
+ narrow_down_nodes(index, desc, spec)
350
+ end
351
+ end
352
+ end
353
+ end
354
+ end
@@ -45,7 +45,7 @@ opts = OptionParser.new do |opts|
45
45
  end
46
46
 
47
47
  opts.on("-j", "--remove-scripts", "Remove <script> elements") do |v|
48
- options[:remove_classes] = v
48
+ options[:remove_scripts] = v
49
49
  end
50
50
 
51
51
  opts.on("-l", "--line-length N", Integer, "Line length for plaintext (default: #{options[:line_length].to_s})") do |v|
@@ -1,4 +1,4 @@
1
1
  class Premailer
2
2
  # Premailer version.
3
- VERSION = '1.8.7'.freeze
3
+ VERSION = '1.9.0'.freeze
4
4
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: premailer
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.8.7
4
+ version: 1.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Dunae
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-06-28 00:00:00.000000000 Z
11
+ date: 2017-01-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: css_parser
@@ -56,7 +56,7 @@ dependencies:
56
56
  name: rake
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
- - - "~>"
59
+ - - ">"
60
60
  - !ruby/object:Gem::Version
61
61
  version: '0.8'
62
62
  - - "!="
@@ -66,7 +66,7 @@ dependencies:
66
66
  prerelease: false
67
67
  version_requirements: !ruby/object:Gem::Requirement
68
68
  requirements:
69
- - - "~>"
69
+ - - ">"
70
70
  - !ruby/object:Gem::Version
71
71
  version: '0.8'
72
72
  - - "!="
@@ -93,6 +93,9 @@ dependencies:
93
93
  - - ">="
94
94
  - !ruby/object:Gem::Version
95
95
  version: 1.4.4
96
+ - - "<="
97
+ - !ruby/object:Gem::Version
98
+ version: 1.6.8
96
99
  type: :development
97
100
  prerelease: false
98
101
  version_requirements: !ruby/object:Gem::Requirement
@@ -100,20 +103,23 @@ dependencies:
100
103
  - - ">="
101
104
  - !ruby/object:Gem::Version
102
105
  version: 1.4.4
106
+ - - "<="
107
+ - !ruby/object:Gem::Version
108
+ version: 1.6.8
103
109
  - !ruby/object:Gem::Dependency
104
110
  name: yard
105
111
  requirement: !ruby/object:Gem::Requirement
106
112
  requirements:
107
- - - "~>"
113
+ - - ">="
108
114
  - !ruby/object:Gem::Version
109
- version: 0.8.7.6
115
+ version: '0'
110
116
  type: :development
111
117
  prerelease: false
112
118
  version_requirements: !ruby/object:Gem::Requirement
113
119
  requirements:
114
- - - "~>"
120
+ - - ">="
115
121
  - !ruby/object:Gem::Version
116
- version: 0.8.7.6
122
+ version: '0'
117
123
  - !ruby/object:Gem::Dependency
118
124
  name: redcarpet
119
125
  requirement: !ruby/object:Gem::Requirement
@@ -199,6 +205,7 @@ files:
199
205
  - lib/premailer/adapter.rb
200
206
  - lib/premailer/adapter/hpricot.rb
201
207
  - lib/premailer/adapter/nokogiri.rb
208
+ - lib/premailer/adapter/nokogiri_fast.rb
202
209
  - lib/premailer/adapter/nokogumbo.rb
203
210
  - lib/premailer/executor.rb
204
211
  - lib/premailer/html_to_plain_text.rb
@@ -207,7 +214,8 @@ files:
207
214
  - misc/client_support.yaml
208
215
  homepage: http://premailer.dialect.ca/
209
216
  licenses: []
210
- metadata: {}
217
+ metadata:
218
+ yard.run: yri
211
219
  post_install_message:
212
220
  rdoc_options: []
213
221
  require_paths:
@@ -229,4 +237,3 @@ signing_key:
229
237
  specification_version: 4
230
238
  summary: Preflight for HTML e-mail.
231
239
  test_files: []
232
- has_rdoc: true