roadie 2.4.3 → 3.0.0.pre1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (87) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +3 -0
  3. data/.travis.yml +9 -14
  4. data/.yardopts +1 -1
  5. data/Changelog.md +22 -10
  6. data/Gemfile +3 -0
  7. data/Guardfile +11 -1
  8. data/README.md +165 -163
  9. data/Rakefile +2 -19
  10. data/lib/roadie.rb +14 -69
  11. data/lib/roadie/asset_provider.rb +7 -58
  12. data/lib/roadie/asset_scanner.rb +92 -0
  13. data/lib/roadie/document.rb +103 -0
  14. data/lib/roadie/errors.rb +57 -0
  15. data/lib/roadie/filesystem_provider.rb +21 -62
  16. data/lib/roadie/inliner.rb +71 -218
  17. data/lib/roadie/markup_improver.rb +88 -0
  18. data/lib/roadie/null_provider.rb +13 -0
  19. data/lib/roadie/null_url_rewriter.rb +12 -0
  20. data/lib/roadie/provider_list.rb +67 -0
  21. data/lib/roadie/rspec.rb +1 -0
  22. data/lib/roadie/rspec/asset_provider.rb +49 -0
  23. data/lib/roadie/selector.rb +42 -18
  24. data/lib/roadie/style_block.rb +33 -0
  25. data/lib/roadie/style_properties.rb +29 -0
  26. data/lib/roadie/style_property.rb +93 -0
  27. data/lib/roadie/stylesheet.rb +65 -0
  28. data/lib/roadie/url_generator.rb +126 -0
  29. data/lib/roadie/url_rewriter.rb +84 -0
  30. data/lib/roadie/version.rb +1 -1
  31. data/roadie.gemspec +6 -10
  32. data/spec/fixtures/big_em.css +1 -0
  33. data/spec/fixtures/stylesheets/green.css +1 -0
  34. data/spec/integration_spec.rb +125 -95
  35. data/spec/lib/roadie/asset_scanner_spec.rb +153 -0
  36. data/spec/lib/roadie/css_not_found_spec.rb +16 -0
  37. data/spec/lib/roadie/document_spec.rb +123 -0
  38. data/spec/lib/roadie/filesystem_provider_spec.rb +25 -72
  39. data/spec/lib/roadie/inliner_spec.rb +105 -537
  40. data/spec/lib/roadie/markup_improver_spec.rb +78 -0
  41. data/spec/lib/roadie/null_provider_spec.rb +21 -0
  42. data/spec/lib/roadie/null_url_rewriter_spec.rb +19 -0
  43. data/spec/lib/roadie/provider_list_spec.rb +81 -0
  44. data/spec/lib/roadie/selector_spec.rb +7 -5
  45. data/spec/lib/roadie/style_block_spec.rb +35 -0
  46. data/spec/lib/roadie/style_properties_spec.rb +61 -0
  47. data/spec/lib/roadie/style_property_spec.rb +82 -0
  48. data/spec/lib/roadie/stylesheet_spec.rb +41 -0
  49. data/spec/lib/roadie/test_provider_spec.rb +29 -0
  50. data/spec/lib/roadie/url_generator_spec.rb +120 -0
  51. data/spec/lib/roadie/url_rewriter_spec.rb +79 -0
  52. data/spec/shared_examples/asset_provider.rb +11 -0
  53. data/spec/shared_examples/url_rewriter.rb +23 -0
  54. data/spec/spec_helper.rb +5 -60
  55. data/spec/support/have_node_matcher.rb +2 -2
  56. data/spec/support/have_selector_matcher.rb +1 -1
  57. data/spec/support/have_styling_matcher.rb +48 -14
  58. data/spec/support/test_provider.rb +13 -0
  59. metadata +73 -177
  60. data/Appraisals +0 -15
  61. data/gemfiles/rails_3.0.gemfile +0 -7
  62. data/gemfiles/rails_3.0.gemfile.lock +0 -123
  63. data/gemfiles/rails_3.1.gemfile +0 -7
  64. data/gemfiles/rails_3.1.gemfile.lock +0 -126
  65. data/gemfiles/rails_3.2.gemfile +0 -7
  66. data/gemfiles/rails_3.2.gemfile.lock +0 -124
  67. data/gemfiles/rails_4.0.gemfile +0 -7
  68. data/gemfiles/rails_4.0.gemfile.lock +0 -119
  69. data/lib/roadie/action_mailer_extensions.rb +0 -95
  70. data/lib/roadie/asset_pipeline_provider.rb +0 -28
  71. data/lib/roadie/css_file_not_found.rb +0 -22
  72. data/lib/roadie/railtie.rb +0 -39
  73. data/lib/roadie/style_declaration.rb +0 -42
  74. data/spec/fixtures/app/assets/stylesheets/integration.css +0 -10
  75. data/spec/fixtures/public/stylesheets/integration.css +0 -10
  76. data/spec/fixtures/views/integration_mailer/marketing.html.erb +0 -2
  77. data/spec/fixtures/views/integration_mailer/notification.html.erb +0 -8
  78. data/spec/fixtures/views/integration_mailer/notification.text.erb +0 -6
  79. data/spec/lib/roadie/action_mailer_extensions_spec.rb +0 -227
  80. data/spec/lib/roadie/asset_pipeline_provider_spec.rb +0 -65
  81. data/spec/lib/roadie/css_file_not_found_spec.rb +0 -29
  82. data/spec/lib/roadie/style_declaration_spec.rb +0 -49
  83. data/spec/lib/roadie_spec.rb +0 -101
  84. data/spec/shared_examples/asset_provider_examples.rb +0 -11
  85. data/spec/support/anonymous_mailer.rb +0 -21
  86. data/spec/support/change_url_options.rb +0 -5
  87. data/spec/support/parse_styling.rb +0 -25
@@ -1,74 +1,33 @@
1
- require 'pathname'
2
-
3
1
  module Roadie
4
- # A provider that looks for files on the filesystem
5
- #
6
- # Usage:
7
- # config.roadie.provider = FilesystemProvider.new("prefix", "path/to/stylesheets")
8
- #
9
- # Path specification follows certain rules thatare detailed in {#initialize}.
2
+ # Asset provider that looks for files on your local filesystem.
10
3
  #
11
- # @see #initialize
12
- class FilesystemProvider < AssetProvider
13
- # @return [Pathname] Pathname representing the directory of the assets
4
+ # It will be locked to a specific path and it will not access files above
5
+ # that directory.
6
+ class FilesystemProvider
7
+ # Raised when FilesystemProvider is asked to access a file that lies above
8
+ # the base path.
9
+ class InsecurePathError < Error; end
10
+
11
+ include AssetProvider
14
12
  attr_reader :path
15
13
 
16
- # Initializes a new instance of FilesystemProvider.
17
- #
18
- # The passed path can come in some variants:
19
- # * +Pathname+ - will be used as-is
20
- # * +String+ - If pointing to an absolute path, uses that path. If a relative path, relative from the +Rails.root+
21
- # * +nil+ - Use the default path (equal to "public/stylesheets")
22
- #
23
- # @example Pointing to a directory in the project
24
- # FilesystemProvider.new(Rails.root.join("public", "assets"))
25
- # FilesystemProvider.new("public/assets")
26
- #
27
- # @example Pointing to external resource
28
- # FilesystemProvider.new("/home/app/stuff")
29
- #
30
- # @param [String] prefix The prefix (see {#prefix})
31
- # @param [String, Pathname, nil] path The path to use
32
- def initialize(prefix = "/stylesheets", path = nil)
33
- super(prefix)
34
- if path
35
- @path = resolve_path(path)
36
- else
37
- @path = default_path
38
- end
14
+ def initialize(path = Dir.pwd)
15
+ @path = path
39
16
  end
40
17
 
41
- # Looks for the file in the tree. If the file cannot be found, and it does not end with ".css", the lookup
42
- # will be retried with ".css" appended to the filename.
43
- #
44
- # @return [String] contents of the file
45
- def find(name)
46
- base = remove_prefix(name)
47
- file = path.join(base)
48
- if file.exist?
49
- file.read.strip
50
- else
51
- return find("#{base}.css") if base.to_s !~ /\.css$/
52
- raise CSSFileNotFound.new(name, base.to_s)
18
+ # @raise InsecurePathError
19
+ # @return [Stylesheet, nil]
20
+ def find_stylesheet(name)
21
+ file_path = build_file_path(name)
22
+ if File.exist? file_path
23
+ Stylesheet.new file_path, File.read(file_path)
53
24
  end
54
25
  end
55
26
 
56
27
  private
57
- def default_path
58
- resolve_path("public/stylesheets")
59
- end
60
-
61
- def resolve_path(path)
62
- if path.kind_of?(Pathname)
63
- @path = path
64
- else
65
- pathname = Pathname.new(path)
66
- if pathname.absolute?
67
- @path = pathname
68
- else
69
- @path = Roadie.app.root.join(path)
70
- end
71
- end
72
- end
28
+ def build_file_path(name)
29
+ raise InsecurePathError, name if name.include?("..")
30
+ File.join(@path, name)
31
+ end
73
32
  end
74
33
  end
@@ -1,247 +1,100 @@
1
1
  require 'set'
2
+ require 'nokogiri'
3
+ require 'uri'
4
+ require 'css_parser'
2
5
 
3
6
  module Roadie
4
- # This class is the core of Roadie as it does all the actual work. You just give it
5
- # the CSS rules, the HTML and the url_options for rewriting URLs and let it go on
6
- # doing all the heavy lifting and building.
7
+ # @api private
8
+ # The Inliner inlines stylesheets to the elements of the DOM.
9
+ #
10
+ # Inlining means that {StyleBlock}s and a DOM tree are combined:
11
+ # a { color: red; } # StyleBlock
12
+ # <a href="/"></a> # DOM
13
+ #
14
+ # becomes
15
+ #
16
+ # <a href="/" style="color:red"></a>
7
17
  class Inliner
8
- # Regexp matching all the url() declarations in CSS
9
- #
10
- # It matches without any quotes and with both single and double quotes
11
- # inside the parenthesis. There's much room for improvement, of course.
12
- CSS_URL_REGEXP = %r{
13
- url\(
14
- (
15
- (?:["']|%22)? # Optional opening quote
16
- )
17
- (
18
- [^(]* # Text leading up to before opening parens
19
- (?:\([^)]*\))* # Texts containing parens pairs
20
- [^(]+ # Texts without parens - required
21
- )
22
- \1 # Closing quote
23
- \)
24
- }x
25
-
26
- # Initialize a new Inliner with the given Provider, CSS targets, HTML, and `url_options`.
27
- #
28
- # @param [AssetProvider] assets
29
- # @param [Array] targets List of CSS files to load via the provider
30
- # @param [String] html
31
- # @param [Hash] url_options Supported keys: +:host+, +:port+, +:protocol+
32
- # @param [lambda] after_inlining_handler A lambda that accepts one parameter or an object that responds to the +call+ method with one parameter.
33
- def initialize(assets, targets, html, url_options, after_inlining_handler=nil)
34
- @assets = assets
35
- @css = assets.all(targets)
36
- @html = html
37
- @inline_css = []
38
- @url_options = url_options
39
- @after_inlining_handler = after_inlining_handler
40
-
41
- if url_options and url_options[:asset_path_prefix]
42
- raise ArgumentError, "The asset_path_prefix URL option is not working anymore. You need to add the following configuration to your application.rb:\n" +
43
- " config.roadie.provider = AssetPipelineProvider.new(#{url_options[:asset_path_prefix].inspect})\n" +
44
- "Note that the prefix \"/assets\" is the default one, so you do not need to configure anything in that case."
45
- end
18
+ # @param [Array<Stylesheet>] stylesheets the stylesheets to use in the inlining
19
+ def initialize(stylesheets)
20
+ @stylesheets = stylesheets
46
21
  end
47
22
 
48
- # Start the inlining and return the final HTML output
49
- # @return [String]
50
- def execute
51
- adjust_html do |document|
52
- @document = document
53
- add_missing_structure
54
- extract_link_elements
55
- extract_inline_style_elements
56
- inline_css_rules
57
- make_image_urls_absolute
58
- make_style_urls_absolute
59
- after_inlining_handler.call(document) if after_inlining_handler.respond_to?(:call)
60
- @document = nil
61
- end
23
+ # Start the inlining, mutating the DOM tree.
24
+ #
25
+ # @param [Nokogiri::HTML::Document] dom
26
+ # @return [nil]
27
+ def inline(dom)
28
+ apply style_map(dom)
29
+ nil
62
30
  end
63
31
 
64
32
  private
65
- attr_reader :css, :html, :assets, :url_options, :document, :after_inlining_handler
66
-
67
- def inline_css
68
- @inline_css.join("\n")
69
- end
70
-
71
- def parsed_css
72
- CssParser::Parser.new.tap do |parser|
73
- parser.add_block! clean_css(css) if css
74
- parser.add_block! clean_css(inline_css)
75
- end
76
- end
77
-
78
- def adjust_html
79
- Nokogiri::HTML.parse(html).tap do |document|
80
- yield document
81
- end.dup.to_html
82
- end
83
-
84
- def add_missing_structure
85
- html_node = document.at_css('html')
86
- html_node['xmlns'] ||= 'http://www.w3.org/1999/xhtml'
87
-
88
- if document.at_css('html > head').present?
89
- head = document.at_css('html > head')
90
- else
91
- head = Nokogiri::XML::Node.new('head', document)
92
- document.at_css('html').children.before(head)
93
- end
94
-
95
- # This is handled automatically by Nokogiri in Ruby 1.9, IF charset of string != utf-8
96
- # We want UTF-8 to be specified as well, so we still do this.
97
- unless document.at_css('html > head > meta[http-equiv=Content-Type]')
98
- meta = Nokogiri::XML::Node.new('meta', document)
99
- meta['http-equiv'] = 'Content-Type'
100
- meta['content'] = 'text/html; charset=UTF-8'
101
- head.add_child(meta)
102
- end
103
- end
104
-
105
- def extract_link_elements
106
- all_link_elements_to_be_inlined_with_url.each do |link, url|
107
- asset = assets.find(url.path)
108
- @inline_css << asset.to_s
109
- link.remove
110
- end
111
- end
112
-
113
- def extract_inline_style_elements
114
- document.css("style").each do |style|
115
- next if style['media'] == 'print' or style['data-immutable']
116
- @inline_css << style.content
117
- style.remove
118
- end
119
- end
33
+ attr_reader :stylesheets
120
34
 
121
- def inline_css_rules
122
- elements_with_declarations.each do |element, declarations|
123
- ordered_declarations = []
124
- seen_properties = Set.new
125
- declarations.sort.reverse_each do |declaration|
126
- next if seen_properties.include?(declaration.property)
127
- ordered_declarations.unshift(declaration)
128
- seen_properties << declaration.property
129
- end
130
-
131
- rules_string = ordered_declarations.map { |declaration| declaration.to_s }.join(';')
132
- element['style'] = [rules_string, element['style']].compact.join(';')
133
- end
134
- end
135
-
136
- def elements_with_declarations
137
- Hash.new { |hash, key| hash[key] = [] }.tap do |element_declarations|
138
- parsed_css.each_rule_set do |rule_set, _|
139
- each_good_selector(rule_set) do |selector|
140
- each_element_in_selector(selector) do |element|
141
- style_declarations_in_rule_set(selector.specificity, rule_set) do |declaration|
142
- element_declarations[element] << declaration
143
- end
144
- end
145
- end
146
- end
147
- end
148
- end
149
-
150
- def each_good_selector(rules)
151
- rules.selectors.each do |selector_string|
152
- selector = Selector.new(selector_string)
153
- yield selector if selector.inlinable?
154
- end
155
- end
156
-
157
- def each_element_in_selector(selector)
158
- document.css(selector.to_s).each do |element|
159
- yield element
160
- end
161
- # There's no way to get a list of supported pseudo rules, so we're left
162
- # with having to rescue errors.
163
- # Pseudo selectors that are known to be bad are skipped automatically but
164
- # this will catch the rest.
165
- rescue Nokogiri::XML::XPath::SyntaxError, Nokogiri::CSS::SyntaxError => error
166
- warn "Roadie cannot use #{selector.inspect} when inlining stylesheets"
167
- rescue => error
168
- warn "Roadie got error when looking for #{selector.inspect}: #{error}"
169
- raise unless error.message.include?('XPath')
35
+ def apply(style_map)
36
+ style_map.each_element do |element, properties|
37
+ apply_element_style element, properties
170
38
  end
39
+ end
171
40
 
172
- def style_declarations_in_rule_set(specificity, rule_set)
173
- rule_set.each_declaration do |property, value, important|
174
- yield StyleDeclaration.new(property, value, important, specificity)
175
- end
176
- end
41
+ def style_map(dom)
42
+ style_map = StyleMap.new
177
43
 
178
- def make_image_urls_absolute
179
- document.css('img').each do |img|
180
- img['src'] = ensure_absolute_url(img['src']) if img['src']
181
- end
44
+ each_inlinable_block do |stylesheet, selector, properties|
45
+ elements = elements_matching_selector(stylesheet, selector, dom)
46
+ style_map.add elements, properties
182
47
  end
183
48
 
184
- def make_style_urls_absolute
185
- document.css('*[style]').each do |element|
186
- styling = element['style']
187
- element['style'] = styling.gsub(CSS_URL_REGEXP) { "url(#{$1}#{ensure_absolute_url($2, '/stylesheets')}#{$1})" }
188
- end
189
- end
49
+ style_map
50
+ end
190
51
 
191
- def ensure_absolute_url(url, base_path = nil)
192
- base, uri = absolute_url_base(base_path), URI.parse(url)
193
- if uri.relative? and base
194
- base.merge(uri).to_s
195
- else
196
- uri.to_s
52
+ def each_inlinable_block
53
+ stylesheets.each do |stylesheet|
54
+ stylesheet.each_inlinable_block do |selector, properties|
55
+ yield stylesheet, selector, properties
197
56
  end
198
- rescue URI::InvalidURIError
199
- return url
200
57
  end
58
+ end
201
59
 
202
- def absolute_url_base(base_path)
203
- return nil unless url_options
204
- port = url_options[:port]
205
- scheme = protocol_to_scheme url_options[:protocol]
206
- URI::Generic.build({
207
- :scheme => scheme,
208
- :host => url_options[:host],
209
- :port => (port ? port.to_i : nil),
210
- :path => base_path
211
- })
212
- end
60
+ def apply_element_style(element, properties)
61
+ element["style"] = [properties.to_s, element["style"]].compact.join(";")
62
+ end
213
63
 
214
- # Strip :// from any protocol, if present
215
- def protocol_to_scheme(protocol)
216
- return 'http' unless protocol
217
- protocol.to_s[/^\w+/]
218
- end
64
+ def elements_matching_selector(stylesheet, selector, dom)
65
+ dom.css(selector.to_s)
66
+ # There's no way to get a list of supported pseudo selectors, so we're left
67
+ # with having to rescue errors.
68
+ # Pseudo selectors that are known to be bad are skipped automatically but
69
+ # this will catch the rest.
70
+ rescue Nokogiri::XML::XPath::SyntaxError, Nokogiri::CSS::SyntaxError => error
71
+ warn "Roadie cannot use #{selector.inspect} (from \"#{stylesheet.name}\" stylesheet) when inlining stylesheets"
72
+ []
73
+ rescue => error
74
+ warn "Roadie got error when looking for #{selector.inspect} (from \"#{stylesheet.name}\" stylesheet): #{error}"
75
+ raise unless error.message.include?('XPath')
76
+ []
77
+ end
219
78
 
220
- def all_link_elements_with_url
221
- document.css("link[rel=stylesheet]").map { |link| [link, URI.parse(link['href'])] }
79
+ # @api private
80
+ # StyleMap is a map between a DOM element and {StyleProperties}. Basically,
81
+ # it's an accumulator for properties, scoped on specific elements.
82
+ class StyleMap
83
+ def initialize
84
+ @map = Hash.new { |hash, key|
85
+ hash[key] = StyleProperties.new([])
86
+ }
222
87
  end
223
88
 
224
- def all_link_elements_to_be_inlined_with_url
225
- all_link_elements_with_url.reject do |link, url|
226
- absolute_path_url = (url.host or url.path.nil?)
227
- blacklisted_element = (link['media'] == 'print' or link['data-immutable'])
228
-
229
- absolute_path_url or blacklisted_element
89
+ def add(elements, new_properties)
90
+ Array(elements).each do |element|
91
+ @map[element].merge!(new_properties)
230
92
  end
231
93
  end
232
94
 
233
- CLEANING_MATCHER = /
234
- (^\s* # Beginning-of-lines matches
235
- (<!\[CDATA\[)|
236
- (<!--+)
237
- )|( # End-of-line matches
238
- (--+>)|
239
- (\]\]>)
240
- $)
241
- /x.freeze
242
-
243
- def clean_css(css)
244
- css.gsub(CLEANING_MATCHER, '')
95
+ def each_element
96
+ @map.each_pair { |element, properties| yield element, properties }
245
97
  end
98
+ end
246
99
  end
247
100
  end
@@ -0,0 +1,88 @@
1
+ module Roadie
2
+ # @api private
3
+ # Class that improves the markup of a HTML DOM tree
4
+ #
5
+ # This class will improve the following aspects of the DOM:
6
+ # * A HTML5 doctype will be added if missing, other doctypes will be left as-is.
7
+ # * Basic HTML elements will be added if missing.
8
+ # * +<html>+
9
+ # * +<head>+
10
+ # * +<body>+
11
+ # * +<meta>+ declaring charset and content-type (text/html)
12
+ #
13
+ # @note Due to a Nokogiri bug, the HTML5 doctype cannot be added under JRuby. No doctype is outputted under JRuby.
14
+ # See https://github.com/sparklemotion/nokogiri/issues/984
15
+ class MarkupImprover
16
+ # The original HTML must also be passed in in order to handle the doctypes
17
+ # since a +Nokogiri::HTML::Document+ will always have a doctype, no matter if
18
+ # the original source had it or not. Reading the raw HTML is the only way to
19
+ # determine if we want to add a HTML5 doctype or not.
20
+ def initialize(dom, original_html)
21
+ @dom = dom
22
+ @html = original_html
23
+ end
24
+
25
+ # @return [nil] passed DOM will be mutated
26
+ def improve
27
+ ensure_doctype_present
28
+ head = ensure_head_element_present
29
+ ensure_declared_charset head
30
+ end
31
+
32
+ private
33
+ attr_reader :dom
34
+
35
+ def ensure_doctype_present
36
+ return if uses_buggy_jruby?
37
+ return if @html.include?('<!DOCTYPE ')
38
+ # Nokogiri adds a "default" doctype to the DOM, which we will remove
39
+ dom.internal_subset.remove unless dom.internal_subset.nil?
40
+ dom.create_internal_subset 'html', nil, nil
41
+ end
42
+
43
+ # JRuby up to at least 1.6.0 has a bug where the doctype of a document cannot be changed.
44
+ # See https://github.com/sparklemotion/nokogiri/issues/984
45
+ def uses_buggy_jruby?
46
+ # No reason to check for version yet since no existing version has a fix.
47
+ defined?(JRuby)
48
+ end
49
+
50
+ def ensure_head_element_present
51
+ if (head = dom.at_xpath('html/head'))
52
+ head
53
+ else
54
+ create_head_element dom.at_xpath('html')
55
+ end
56
+ end
57
+
58
+ def create_head_element(parent)
59
+ head = Nokogiri::XML::Node.new 'head', dom
60
+ unless parent.children.empty?
61
+ # Crashes when no children are present
62
+ parent.children.before head
63
+ else
64
+ parent << head
65
+ end
66
+ head
67
+ end
68
+
69
+ def ensure_declared_charset(parent)
70
+ if content_type_meta_element_missing?
71
+ parent.add_child make_content_type_element
72
+ end
73
+ end
74
+
75
+ def content_type_meta_element_missing?
76
+ dom.xpath('html/head/meta').none? do |meta|
77
+ meta['http-equiv'].downcase == 'content-type'
78
+ end
79
+ end
80
+
81
+ def make_content_type_element
82
+ meta = Nokogiri::XML::Node.new('meta', dom)
83
+ meta['http-equiv'] = 'Content-Type'
84
+ meta['content'] = 'text/html; charset=UTF-8'
85
+ meta
86
+ end
87
+ end
88
+ end