roadie 3.0.5 → 3.1.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -7
  3. data/.travis.yml +1 -5
  4. data/Changelog.md +20 -1
  5. data/Guardfile +2 -1
  6. data/README.md +193 -53
  7. data/lib/roadie.rb +3 -2
  8. data/lib/roadie/asset_scanner.rb +30 -10
  9. data/lib/roadie/cached_provider.rb +77 -0
  10. data/lib/roadie/document.rb +29 -10
  11. data/lib/roadie/errors.rb +43 -1
  12. data/lib/roadie/filesystem_provider.rb +3 -1
  13. data/lib/roadie/inliner.rb +57 -27
  14. data/lib/roadie/markup_improver.rb +2 -1
  15. data/lib/roadie/net_http_provider.rb +70 -0
  16. data/lib/roadie/null_provider.rb +3 -0
  17. data/lib/roadie/path_rewriter_provider.rb +64 -0
  18. data/lib/roadie/provider_list.rb +19 -1
  19. data/lib/roadie/rspec.rb +1 -0
  20. data/lib/roadie/rspec/cache_store.rb +25 -0
  21. data/lib/roadie/stylesheet.rb +1 -0
  22. data/lib/roadie/url_generator.rb +2 -1
  23. data/lib/roadie/utils.rb +9 -0
  24. data/lib/roadie/version.rb +1 -1
  25. data/roadie.gemspec +2 -1
  26. data/spec/hash_as_cache_store_spec.rb +7 -0
  27. data/spec/integration_spec.rb +91 -0
  28. data/spec/lib/roadie/asset_scanner_spec.rb +79 -25
  29. data/spec/lib/roadie/cached_provider_spec.rb +52 -0
  30. data/spec/lib/roadie/document_spec.rb +43 -7
  31. data/spec/lib/roadie/filesystem_provider_spec.rb +5 -0
  32. data/spec/lib/roadie/inliner_spec.rb +72 -15
  33. data/spec/lib/roadie/net_http_provider_spec.rb +89 -0
  34. data/spec/lib/roadie/path_rewriter_provider_spec.rb +39 -0
  35. data/spec/lib/roadie/provider_list_spec.rb +31 -8
  36. data/spec/lib/roadie/stylesheet_spec.rb +14 -8
  37. data/spec/lib/roadie/utils_spec.rb +7 -0
  38. data/spec/spec_helper.rb +1 -0
  39. data/spec/support/have_styling_matcher.rb +1 -0
  40. metadata +40 -9
  41. data/lib/roadie/upgrade_guide.rb +0 -36
@@ -16,6 +16,9 @@ require 'roadie/asset_provider'
16
16
  require 'roadie/provider_list'
17
17
  require 'roadie/filesystem_provider'
18
18
  require 'roadie/null_provider'
19
+ require 'roadie/net_http_provider'
20
+ require 'roadie/cached_provider'
21
+ require 'roadie/path_rewriter_provider'
19
22
 
20
23
  require 'roadie/asset_scanner'
21
24
  require 'roadie/markup_improver'
@@ -24,5 +27,3 @@ require 'roadie/url_rewriter'
24
27
  require 'roadie/null_url_rewriter'
25
28
  require 'roadie/inliner'
26
29
  require 'roadie/document'
27
-
28
- require 'roadie/upgrade_guide'
@@ -5,15 +5,18 @@ module Roadie
5
5
  # DOM tree. Referenced styles will be found using the provided asset
6
6
  # provider.
7
7
  #
8
- # Any style declaration tagged with +data-roadie-ignore+ will be ignored.
8
+ # Any style declaration tagged with +data-roadie-ignore+ will be ignored,
9
+ # except for having the attribute itself removed.
9
10
  class AssetScanner
10
- attr_reader :dom, :asset_provider
11
+ attr_reader :dom, :normal_asset_provider, :external_asset_provider
11
12
 
12
13
  # @param [Nokogiri::HTML::Document] dom
13
- # @param [#find_stylesheet!] asset_provider
14
- def initialize(dom, asset_provider)
14
+ # @param [#find_stylesheet!] normal_asset_provider
15
+ # @param [#find_stylesheet!] external_asset_provider
16
+ def initialize(dom, normal_asset_provider, external_asset_provider)
15
17
  @dom = dom
16
- @asset_provider = asset_provider
18
+ @normal_asset_provider = normal_asset_provider
19
+ @external_asset_provider = external_asset_provider
17
20
  end
18
21
 
19
22
  # Looks for all non-ignored stylesheets and returns them.
@@ -38,15 +41,16 @@ module Roadie
38
41
  # @see #find_css
39
42
  # @return [Enumerable<Stylesheet>] every extracted stylesheet
40
43
  def extract_css
41
- @dom.css(STYLE_ELEMENT_QUERY).map { |element|
44
+ stylesheets = @dom.css(STYLE_ELEMENT_QUERY).map { |element|
42
45
  stylesheet = read_stylesheet(element)
43
46
  element.remove if stylesheet
44
47
  stylesheet
45
48
  }.compact
49
+ remove_ignore_markers
50
+ stylesheets
46
51
  end
47
52
 
48
53
  private
49
-
50
54
  STYLE_ELEMENT_QUERY = (
51
55
  "style:not([data-roadie-ignore]), " +
52
56
  # TODO: When using Nokogiri 1.6.1 and later; we may use a double :not here
@@ -70,7 +74,7 @@ module Roadie
70
74
  def read_stylesheet(element)
71
75
  if element.name == "style"
72
76
  read_style_element element
73
- else
77
+ elsif element.name == "link" && element['media'] != "print" && element["href"]
74
78
  read_link_element element
75
79
  end
76
80
  end
@@ -80,13 +84,29 @@ module Roadie
80
84
  end
81
85
 
82
86
  def read_link_element(element)
83
- if element['media'] != "print" && element["href"] && !Utils.path_is_absolute?(element["href"])
84
- asset_provider.find_stylesheet! element['href']
87
+ if Utils.path_is_absolute?(element["href"])
88
+ external_asset_provider.find_stylesheet! element['href'] if should_find_external?
89
+ else
90
+ normal_asset_provider.find_stylesheet! element['href']
85
91
  end
86
92
  end
87
93
 
88
94
  def clean_css(css)
89
95
  css.gsub(CLEANING_MATCHER, '')
90
96
  end
97
+
98
+ def should_find_external?
99
+ return false unless external_asset_provider
100
+ # If external_asset_provider is empty list; don't use it.
101
+ return false if external_asset_provider.respond_to?(:empty?) && external_asset_provider.empty?
102
+
103
+ true
104
+ end
105
+
106
+ def remove_ignore_markers
107
+ @dom.css("[data-roadie-ignore]").each do |node|
108
+ node.remove_attribute "data-roadie-ignore"
109
+ end
110
+ end
91
111
  end
92
112
  end
@@ -0,0 +1,77 @@
1
+ module Roadie
2
+ # @api public
3
+ # The {CachedProvider} wraps another provider (or {ProviderList}) and caches
4
+ # the response from it.
5
+ #
6
+ # The default cache store is a instance-specific hash that lives for the
7
+ # entire duration of the instance. If you want to share hash between
8
+ # instances, pass your own hash-like object. Just remember to not allow this
9
+ # cache to grow without bounds, which a shared hash would do.
10
+ #
11
+ # Not found assets are not cached currently, but it's possible to extend this
12
+ # class in the future if there is a need for it. Remember this if you have
13
+ # providers with very slow failures.
14
+ #
15
+ # The cache store must accept {Roadie::Stylesheet} instances, and return such
16
+ # instances when fetched. It must respond to `#[name]` and `#[name]=` to
17
+ # retrieve and set entries, respectively. The `#[name]=` method also needs to
18
+ # return the instance again.
19
+ #
20
+ # @example Global cache
21
+ # Application.asset_cache = Hash.new
22
+ # slow_provider = MyDatabaseProvider.new(Application)
23
+ # provider = Roadie::CachedProvider.new(slow_provider, Application.asset_cache)
24
+ #
25
+ # @example Custom cache store
26
+ # class MyRoadieMemcacheStore
27
+ # def initialize(memcache)
28
+ # @memcache = memcache
29
+ # end
30
+ #
31
+ # def [](path)
32
+ # css = memcache.read("assets/#{path}/css")
33
+ # if css
34
+ # name = memcache.read("assets/#{path}/name") || "cached #{path}"
35
+ # Roadie::Stylesheet.new(name, css)
36
+ # end
37
+ # end
38
+ #
39
+ # def []=(path, stylesheet)
40
+ # memcache.write("assets/#{path}/css", stylesheet.to_s)
41
+ # memcache.write("assets/#{path}/name", stylesheet.name)
42
+ # stylesheet # You need to return the set Stylesheet
43
+ # end
44
+ # end
45
+ #
46
+ class CachedProvider
47
+ # The cache store used by this instance.
48
+ attr_reader :cache
49
+
50
+ # @param upstream [an asset provider] The wrapped asset provider
51
+ # @param cache [#[], #[]=] The cache store to use.
52
+ def initialize(upstream, cache = {})
53
+ @upstream = upstream
54
+ @cache = cache
55
+ end
56
+
57
+ def find_stylesheet(name)
58
+ cache_fetch(name) do
59
+ @upstream.find_stylesheet(name)
60
+ end
61
+ end
62
+
63
+ def find_stylesheet!(name)
64
+ cache_fetch(name) do
65
+ @upstream.find_stylesheet!(name)
66
+ end
67
+ end
68
+
69
+ private
70
+ def cache_fetch(name)
71
+ cache[name] || cache[name] = yield
72
+ rescue CssNotFound
73
+ cache[name] = nil
74
+ raise
75
+ end
76
+ end
77
+ end
@@ -13,10 +13,10 @@ module Roadie
13
13
  # The internal stylesheet is used last and gets the highest priority. The
14
14
  # rest is used in the same order as browsers are supposed to use them.
15
15
  #
16
- # @attr [#call] before_transformation Callback to call just before {#transform}ation is begun. Will be called with the parsed DOM tree.
17
- # @attr [#call] after_transformation Callback to call just before {#transform}ation is completed. Will be called with the current DOM tree.
16
+ # @attr [#call] before_transformation Callback to call just before {#transform}ation begins. Will be called with the parsed DOM tree and the {Document} instance.
17
+ # @attr [#call] after_transformation Callback to call just before {#transform}ation is completed. Will be called with the current DOM tree and the {Document} instance.
18
18
  class Document
19
- attr_reader :html, :asset_providers
19
+ attr_reader :html, :asset_providers, :external_asset_providers
20
20
 
21
21
  # URL options. If none are given no URL rewriting will take place.
22
22
  # @see UrlGenerator#initialize
@@ -24,10 +24,15 @@ module Roadie
24
24
 
25
25
  attr_accessor :before_transformation, :after_transformation
26
26
 
27
+ # Should CSS that cannot be inlined be kept in a new `<style>` element in `<head>`?
28
+ attr_accessor :keep_uninlinable_css
29
+
27
30
  # @param [String] html the input HTML
28
31
  def initialize(html)
32
+ @keep_uninlinable_css = true
29
33
  @html = html
30
34
  @asset_providers = ProviderList.wrap(FilesystemProvider.new)
35
+ @external_asset_providers = ProviderList.empty
31
36
  @css = ""
32
37
  end
33
38
 
@@ -39,9 +44,10 @@ module Roadie
39
44
 
40
45
  # Transform the input HTML and returns the processed HTML.
41
46
  #
42
- # Before the transformation begins, the {#before_transformation} callback will be
43
- # called with the parsed HTML tree, and after all work is complete the
44
- # {#after_transformation} callback will be invoked.
47
+ # Before the transformation begins, the {#before_transformation} callback
48
+ # will be called with the parsed HTML tree and the {Document} instance, and
49
+ # after all work is complete the {#after_transformation} callback will be
50
+ # invoked in the same way.
45
51
  #
46
52
  # Most of the work is delegated to other classes. A list of them can be seen below.
47
53
  #
@@ -65,11 +71,16 @@ module Roadie
65
71
  dom.dup.to_html
66
72
  end
67
73
 
68
- # Assign new asset providers. The supplied list will be wrapped in a {ProviderList} using {ProviderList.wrap}.
74
+ # Assign new normal asset providers. The supplied list will be wrapped in a {ProviderList} using {ProviderList.wrap}.
69
75
  def asset_providers=(list)
70
76
  @asset_providers = ProviderList.wrap(list)
71
77
  end
72
78
 
79
+ # Assign new external asset providers. The supplied list will be wrapped in a {ProviderList} using {ProviderList.wrap}.
80
+ def external_asset_providers=(list)
81
+ @external_asset_providers = ProviderList.wrap(list)
82
+ end
83
+
73
84
  private
74
85
  def stylesheet
75
86
  Stylesheet.new "(Document styles)", @css
@@ -80,8 +91,8 @@ module Roadie
80
91
  end
81
92
 
82
93
  def inline(dom)
83
- dom_stylesheets = AssetScanner.new(dom, asset_providers).extract_css
84
- Inliner.new(dom_stylesheets + [stylesheet]).inline(dom)
94
+ dom_stylesheets = AssetScanner.new(dom, asset_providers, external_asset_providers).extract_css
95
+ Inliner.new(dom_stylesheets + [stylesheet], dom).inline(keep_uninlinable_css)
85
96
  end
86
97
 
87
98
  def rewrite_urls(dom)
@@ -97,7 +108,15 @@ module Roadie
97
108
  end
98
109
 
99
110
  def callback(callable, dom)
100
- callable.(dom) if callable.respond_to?(:call)
111
+ if callable.respond_to?(:call)
112
+ # Arity checking is to support the API without bumping a major version.
113
+ # TODO: Remove on next major version (v4.0)
114
+ if !callable.respond_to?(:parameters) || callable.parameters.size == 1
115
+ callable.(dom)
116
+ else
117
+ callable.(dom, self)
118
+ end
119
+ end
101
120
  end
102
121
  end
103
122
  end
@@ -38,19 +38,61 @@ module Roadie
38
38
  # Provider used when finding
39
39
  attr_reader :provider
40
40
 
41
+ # Extra message
42
+ attr_reader :extra_message
43
+
41
44
  # TODO: Change signature in the next major version of Roadie.
42
45
  def initialize(css_name, extra_message = nil, provider = nil)
43
46
  @css_name = css_name
44
47
  @provider = provider
48
+ @extra_message = extra_message
45
49
  super build_message(extra_message)
46
50
  end
47
51
 
52
+ protected
53
+ def error_row
54
+ "#{provider || "Unknown provider"}: #{extra_message || message}"
55
+ end
56
+
48
57
  private
49
- def build_message(extra_message)
58
+ # Redundant method argument is to keep API compatability without major version bump.
59
+ # TODO: Remove argument on version 4.0.
60
+ def build_message(extra_message = @extra_message)
50
61
  message = %(Could not find stylesheet "#{css_name}")
51
62
  message << ": #{extra_message}" if extra_message
52
63
  message << "\nUsed provider:\n#{provider}" if provider
53
64
  message
54
65
  end
55
66
  end
67
+
68
+ class ProvidersFailed < CssNotFound
69
+ attr_reader :errors
70
+
71
+ def initialize(css_name, provider_list, errors)
72
+ @errors = errors
73
+ super(css_name, "All providers failed", provider_list)
74
+ end
75
+
76
+ private
77
+ def build_message(extra_message)
78
+ message = %(Could not find stylesheet "#{css_name}": #{extra_message}\nUsed providers:\n)
79
+ each_error_row(errors) do |row|
80
+ message << "\t" << row << "\n"
81
+ end
82
+ message
83
+ end
84
+
85
+ def each_error_row(errors)
86
+ errors.each do |error|
87
+ case error
88
+ when ProvidersFailed
89
+ each_error_row(error.errors) { |row| yield row }
90
+ when CssNotFound
91
+ yield error.error_row
92
+ else
93
+ yield "Unknown provider (#{error.class}): #{error}"
94
+ end
95
+ end
96
+ end
97
+ end
56
98
  end
@@ -8,7 +8,6 @@ module Roadie
8
8
  # the base path.
9
9
  class InsecurePathError < Error; end
10
10
 
11
- include AssetProvider
12
11
  attr_reader :path
13
12
 
14
13
  def initialize(path = Dir.pwd)
@@ -35,6 +34,9 @@ module Roadie
35
34
  end
36
35
  end
37
36
 
37
+ def to_s() inspect end
38
+ def inspect() "#<#{self.class} #@path>" end
39
+
38
40
  private
39
41
  def build_file_path(name)
40
42
  raise InsecurePathError, name if name.include?("..")
@@ -16,64 +16,94 @@ module Roadie
16
16
  # <a href="/" style="color:red"></a>
17
17
  class Inliner
18
18
  # @param [Array<Stylesheet>] stylesheets the stylesheets to use in the inlining
19
- def initialize(stylesheets)
19
+ # @param [Nokogiri::HTML::Document] dom
20
+ def initialize(stylesheets, dom)
20
21
  @stylesheets = stylesheets
22
+ @dom = dom
21
23
  end
22
24
 
23
25
  # Start the inlining, mutating the DOM tree.
24
26
  #
25
- # @param [Nokogiri::HTML::Document] dom
27
+ # @param [true, false] keep_extra_blocks
26
28
  # @return [nil]
27
- def inline(dom)
28
- apply style_map(dom)
29
- nil
30
- end
29
+ def inline(keep_extra_blocks = true)
30
+ style_map, extra_blocks = consume_stylesheets
31
31
 
32
- private
33
- attr_reader :stylesheets
32
+ apply_style_map(style_map)
33
+ add_styles_to_head(extra_blocks) if keep_extra_blocks
34
34
 
35
- def apply(style_map)
36
- style_map.each_element do |element, builder|
37
- apply_element_style element, builder
38
- end
35
+ nil
39
36
  end
40
37
 
41
- def style_map(dom)
38
+ protected
39
+ attr_reader :stylesheets, :dom
40
+
41
+ private
42
+ def consume_stylesheets
42
43
  style_map = StyleMap.new
44
+ extra_blocks = []
43
45
 
44
- each_inlinable_block do |stylesheet, selector, properties|
45
- elements = elements_matching_selector(stylesheet, selector, dom)
46
- style_map.add elements, properties
46
+ each_style_block do |stylesheet, block|
47
+ if (elements = selector_elements(stylesheet, block))
48
+ style_map.add elements, block.properties
49
+ else
50
+ extra_blocks << block
51
+ end
47
52
  end
48
53
 
49
- style_map
54
+ [style_map, extra_blocks]
50
55
  end
51
56
 
52
- def each_inlinable_block
57
+ def each_style_block
53
58
  stylesheets.each do |stylesheet|
54
- stylesheet.each_inlinable_block do |selector, properties|
55
- yield stylesheet, selector, properties
59
+ stylesheet.blocks.each do |block|
60
+ yield stylesheet, block
56
61
  end
57
62
  end
58
63
  end
59
64
 
65
+ def selector_elements(stylesheet, block)
66
+ block.inlinable? && elements_matching_selector(stylesheet, block.selector)
67
+ end
68
+
69
+ def apply_style_map(style_map)
70
+ style_map.each_element { |element, builder| apply_element_style(element, builder) }
71
+ end
72
+
60
73
  def apply_element_style(element, builder)
61
74
  element["style"] = [builder.attribute_string, element["style"]].compact.join(";")
62
75
  end
63
76
 
64
- def elements_matching_selector(stylesheet, selector, dom)
77
+ def elements_matching_selector(stylesheet, selector)
65
78
  dom.css(selector.to_s)
66
79
  # There's no way to get a list of supported pseudo selectors, so we're left
67
80
  # with having to rescue errors.
68
81
  # Pseudo selectors that are known to be bad are skipped automatically but
69
82
  # this will catch the rest.
70
83
  rescue Nokogiri::XML::XPath::SyntaxError, Nokogiri::CSS::SyntaxError => error
71
- warn "Roadie cannot use #{selector.inspect} (from \"#{stylesheet.name}\" stylesheet) when inlining stylesheets"
72
- []
84
+ Utils.warn "Cannot inline #{selector.inspect} from \"#{stylesheet.name}\" stylesheet. If this is valid CSS, please report a bug."
85
+ nil
73
86
  rescue => error
74
- warn "Roadie got error when looking for #{selector.inspect} (from \"#{stylesheet.name}\" stylesheet): #{error}"
87
+ Utils.warn "Got error when looking for #{selector.inspect} (from \"#{stylesheet.name}\" stylesheet): #{error}"
75
88
  raise unless error.message.include?('XPath')
76
- []
89
+ nil
90
+ end
91
+
92
+ def add_styles_to_head(blocks)
93
+ unless blocks.empty?
94
+ create_style_element(blocks, find_head)
95
+ end
96
+ end
97
+
98
+ def find_head
99
+ dom.at_xpath('html/head')
100
+ end
101
+
102
+ def create_style_element(style_blocks, head)
103
+ return unless head
104
+ element = Nokogiri::XML::Node.new("style", head.document)
105
+ element.content = style_blocks.join("\n")
106
+ head.add_child(element)
77
107
  end
78
108
 
79
109
  # @api private
@@ -94,8 +124,8 @@ module Roadie
94
124
  end
95
125
  end
96
126
 
97
- def each_element
98
- @map.each_pair { |element, builder| yield element, builder }
127
+ def each_element(&block)
128
+ @map.each_pair(&block)
99
129
  end
100
130
  end
101
131
  end