roadie-5.1.0/0000755000004100000410000000000014337365432013022 5ustar www-datawww-dataroadie-5.1.0/README.md0000644000004100000410000006074114337365432014311 0ustar www-datawww-dataRoadie ====== [![Code Climate](https://codeclimate.com/github/Mange/roadie.png)](https://codeclimate.com/github/Mange/roadie) [![Code coverage status](https://codecov.io/github/Mange/roadie/coverage.svg?branch=master)](https://codecov.io/github/Mange/roadie?branch=master) [![Gem](https://img.shields.io/gem/v/roadie.svg)](https://rubygems.org/gems/roadie) [![Passive maintenance](https://img.shields.io/badge/maintenance-Passive-yellow.svg)][passive] ||| |---|---| | :warning: | This gem is now in [passive maintenance mode][passive]. [(more)][passive] | > Making HTML emails comfortable for the Ruby rockstars Roadie tries to make sending HTML emails a little less painful by inlining stylesheets and rewriting relative URLs for you inside your emails. How does it work? ----------------- Email clients have bad support for stylesheets, and some of them blocks stylesheets from downloading. The easiest way to handle this is to work with inline styles (`style="..."`), but that is error prone and hard to work with as you cannot use classes and/or reuse styling over your HTML. This gem makes this easier by automatically inlining stylesheets into the document. You give Roadie your CSS, or let it find it by itself from the `` and ` ``` Roadie will use the given asset providers to look for the actual CSS that is referenced. If you don't change the default, it will use the `Roadie::FilesystemProvider` which looks for stylesheets on the filesystem, relative to the current working directory. Example: ```ruby # /home/user/foo/stylesheets/primary.css body { color: green; } # /home/user/foo/script.rb html = <<-HTML HTML Dir.pwd # => "/home/user/foo" document = Roadie::Document.new html document.transform # => # # # # # ``` If a referenced stylesheet cannot be found, the `#transform` method will raise an `Roadie::CssNotFound` error. If you instead want to ignore missing stylesheets, you can use the `NullProvider`. ### Configuring providers ### You can write your own providers if you need very specific behavior for your app, or you can use the built-in providers. Providers come in two groups: normal and external. Normal providers handle paths without host information (`/style/foo.css`) while external providers handle URLs with host information (`//example.com/foo.css`, `localhost:3001/bar.css`, and so on). The default configuration is to not have any external providers configured, which will cause those referenced stylesheets to be ignored. Adding one or more providers for external assets causes all of them to be searched and inlined, so if you only want this to happen to specific stylesheets you need to add ignore markers to every other styleshheet (see above). Included providers: * `FilesystemProvider` – Looks for files on the filesystem, relative to the given directory unless otherwise specified. * `ProviderList` – Wraps a list of other providers and searches them in order. The `asset_providers` setting is an instance of this. It behaves a lot like an array, so you can push, pop, shift and unshift to it. * `NullProvider` – Does not actually provide anything, it always finds empty stylesheets. Use this in tests or if you want to ignore stylesheets that cannot be found by your other providers (or if you want to force the other providers to never run). * `NetHttpProvider` – Downloads stylesheets using `Net::HTTP`. Can be given a whitelist of hosts to download from. * `CachedProvider` – Wraps another provider (or `ProviderList`) and caches responses inside the provided cache store. * `PathRewriterProvider` – Rewrites the passed path and then passes it on to another provider (or `ProviderList`). If you want to search several locations on the filesystem, you can declare that: ```ruby document.asset_providers = [ Roadie::FilesystemProvider.new(App.root.join("resources", "stylesheets")), Roadie::FilesystemProvider.new(App.root.join("system", "uploads", "stylesheets")), ] ``` #### `NullProvider` #### If you want to ignore stylesheets that cannot be found instead of crashing, push the `NullProvider` to the end: ```ruby # Don't crash on missing assets document.asset_providers << Roadie::NullProvider.new # Don't download assets in tests document.external_asset_providers.unshift Roadie::NullProvider.new ``` **Note:** This will cause the referenced stylesheet to be removed from the source code, so email client will never see it either. #### `NetHttpProvider` #### The `NetHttpProvider` will download the URLs that is is given using Ruby's standard `Net::HTTP` library. You can give it a whitelist of hosts that downloads are allowed from: ```ruby document.external_asset_providers << Roadie::NetHttpProvider.new( whitelist: ["myapp.com", "assets.myapp.com", "cdn.cdnnetwork.co.jp"], ) document.external_asset_providers << Roadie::NetHttpProvider.new # Allows every host ``` #### `CachedProvider` #### You might want to cache providers from working several times. If you are sending several emails quickly from the same process, this might also save a lot of time on parsing the stylesheets if you use in-memory storage such as a hash. You can wrap any other kind of providers with it, even a `ProviderList`: ```ruby document.external_asset_providers = Roadie::CachedProvider.new(document.external_asset_providers, my_cache) ``` If you don't pass a cache backend, it will use a normal `Hash`. The cache store must follow this protocol: ```ruby my_cache["key"] = some_stylesheet_instance # => # my_cache["key"] # => # my_cache["missing"] # => nil ``` **Warning:** The default `Hash` store will never be cleared, so make sure you don't allow the number of unique asset paths to grow too large in a single run. This is especially important if you run Roadie in a daemon that accepts arbritary documents, and/or if you use hash digests in your filenames. Making a new instance of `CachedProvider` will use a new `Hash` instance. You can implement your own custom cache store by implementing the `[]` and `[]=` methods. ```ruby class MyRoadieMemcacheStore def initialize(memcache) @memcache = memcache end def [](path) css = memcache.read("assets/#{path}/css") if css name = memcache.read("assets/#{path}/name") || "cached #{path}" Roadie::Stylesheet.new(name, css) end end def []=(path, stylesheet) memcache.write("assets/#{path}/css", stylesheet.to_s) memcache.write("assets/#{path}/name", stylesheet.name) stylesheet # You need to return the set Stylesheet end end document.external_asset_providers = Roadie::CachedProvider.new( document.external_asset_providers, MyRoadieMemcacheStore.new(MemcacheClient.instance) ) ``` If you are using Rspec, you can test your implementation by using the shared examples for the "roadie cache store" role: ```ruby require "roadie/rspec" describe MyRoadieMemcacheStore do let(:memcache_client) { MemcacheClient.instance } subject { MyRoadieMemcacheStore.new(memcache_client) } it_behaves_like "roadie cache store" do before { memcache_client.clear } end end ``` #### `PathRewriterProvider` #### With this provider, you can rewrite the paths that are searched in order to more easily support another provider. Examples could include rewriting absolute URLs into something that can be found on the filesystem, or to access internal hosts instead of external ones. ```ruby filesystem = Roadie::FilesystemProvider.new("assets") document.asset_providers << Roadie::PathRewriterProvider.new(filesystem) do |path| path.sub('stylesheets', 'css').downcase end document.external_asset_providers = Roadie::PathRewriterProvider.new(filesystem) do |url| if url =~ /myapp\.com/ URI.parse(url).path.sub(%r{^/assets}, '') else url end end ``` You can also wrap a list, for example to implement `external_asset_providers` by composing the normal `asset_providers`: ```ruby document.external_asset_providers = Roadie::PathRewriterProvider.new(document.asset_providers) do |url| URI.parse(url).path end ``` ### Writing your own provider ### Writing your own provider is also easy. You need to provide: * `#find_stylesheet(name)`, returning either a `Roadie::Stylesheet` or `nil`. * `#find_stylesheet!(name)`, returning either a `Roadie::Stylesheet` or raising `Roadie::CssNotFound`. ```ruby class UserAssetsProvider def initialize(user_collection) @user_collection = user_collection end def find_stylesheet(name) if name =~ %r{^/users/(\d+)\.css$} user = @user_collection.find_user($1) Roadie::Stylesheet.new("user #{user.id} stylesheet", user.stylesheet) end end def find_stylesheet!(name) find_stylesheet(name) or raise Roadie::CssNotFound.new( css_name: name, message: "does not match a user stylesheet", provider: self ) end # Instead of implementing #find_stylesheet!, you could also: # include Roadie::AssetProvider # That will give you a default implementation without any error message. If # you have multiple error cases, it's recommended that you implement # #find_stylesheet! without #find_stylesheet and raise with an explanatory # error message. end # Try to look for a user stylesheet first, then fall back to normal filesystem lookup. document.asset_providers = [ UserAssetsProvider.new(app), Roadie::FilesystemProvider.new('./stylesheets'), ] ``` You can test for compliance by using the built-in RSpec examples: ```ruby require 'spec_helper' require 'roadie/rspec' describe MyOwnProvider do # Will use the default `subject` (MyOwnProvider.new) it_behaves_like "roadie asset provider", valid_name: "found.css", invalid_name: "does_not_exist.css" # Extra setup just for these tests: it_behaves_like "roadie asset provider", valid_name: "found.css", invalid_name: "does_not_exist.css" do subject { MyOwnProvider.new(...) } before { stub_dependencies } end end ``` ### Keeping CSS that is impossible to inline Some CSS is impossible to inline properly. `:hover` and `::after` comes to mind. Roadie tries its best to keep these around by injecting them inside a new `

Hello world

``` When hovering over this `

`, the color will not change as the `color: green` rule takes precedence. You can get it to work by adding `!important` to the `:hover` rule. It would be foolish to try to automatically inject `!important` on every rule automatically, so this is a manual process. #### Turning it off #### If you'd rather skip this and have the styles not possible to inline disappear, you can turn off this feature by setting the `keep_uninlinable_css` option to false. ```ruby document.keep_uninlinable_css = false ``` ### Callbacks ### Callbacks allow you to do custom work on documents before they are transformed. The Nokogiri document tree is passed to the callable along with the `Roadie::Document` instance: ```ruby class TrackNewsletterLinks def call(dom, document) dom.css("a").each { |link| fix_link(link) } end def fix_link(link) divider = (link['href'] =~ /?/ ? '&' : '?') link['href'] = link['href'] + divider + 'source=newsletter' end end document.before_transformation = ->(dom, document) { logger.debug "Inlining document with title #{dom.at_css('head > title').try(:text)}" } document.after_transformation = TrackNewsletterLinks.new ``` ### XHTML vs HTML ### You can configure the underlying HTML/XML engine to output XHTML or HTML (which is the default). One usecase for this is that `{` tokens usually gets escaped to `{`, which would be a problem if you then pass the resulting HTML on to some other templating engine that uses those tokens (like Handlebars or Mustache). ```ruby document.mode = :xhtml ``` This will also affect the emitted `` if transforming a full document. Partial documents does not have a ``. Build Status ------------ Tested with Github CI using: * MRI 2.6 * MRI 2.7 * MRI 3.0 * MRI 3.1 Let me know if you want any other runtime supported officially. ### Versioning ### This project follows [Semantic Versioning](http://semver.org/) and has been since version 1.0.0. FAQ --- ### Why is my markup changed in subtle ways? Roadie uses Nokogiri to parse and regenerate the HTML of your email, which means that some unintentional changes might show up. One example would be that Nokogiri might remove your ` `s in some cases. Another example is Nokogiri's lack of HTML5 support, so certain new element might have spaces removed. I recommend you don't use HTML5 in emails anyway because of bad email client support (that includes web mail!). ### I'm getting segmentation faults (or other C-like problems)! What should I do? ### Roadie uses Nokogiri to parse the HTML of your email, so any C-like problems like segfaults are likely in that end. The best way to fix this is to first upgrade libxml2 on your system and then reinstall Nokogiri. Instructions on how to do this on most platforms, see [Nokogiri's official install guide](http://nokogiri.org/tutorials/installing_nokogiri.html). ### What happened to my `@keyframes`? The CSS Parser used in Roadie does not handle keyframes. I don't think any email clients do either, but if you want to keep on trying you can add them manually to a ` About us HTML document.asset_providers = TestProvider.new( "/style.css" => "a { background: url(/assets/link-abcdef1234567890.png); }" ) document.url_options = {host: "myapp.com", scheme: "https", path: "rails/app/"} result = parse_html document.transform expect(result.at_css("a")["href"]).to eq("https://myapp.com/rails/app/about_us") expect(result.at_css("img")["src"]).to eq("https://myapp.com/rails/app/assets/about_us-abcdef1234567890.png") expect(result).to have_styling( "background" => 'url("https://myapp.com/rails/app/assets/bg-abcdef1234567890.png")' ).at_selector("body") expect(result).to have_styling( "background" => "url(https://myapp.com/rails/app/assets/link-abcdef1234567890.png)" ).at_selector("a") end it "does not change URLs of ignored elements, but still inlines styles on them" do document = Roadie::Document.new <<-HTML About us Unsubscribe HTML document.url_options = {host: "myapp.com", scheme: "https", path: "rails/app/"} result = parse_html document.transform expect(result.at_css("a.one")["href"]).to eq("https://myapp.com/rails/app/about_us") # Nokogiri still treats the attribute as an URL and escapes it. expect(result.at_css("a.two")["href"]).to eq("%24UNSUBSCRIBE_URL") expect(result).to have_styling("color" => "green").at_selector("a.one") expect(result).to have_styling("color" => "green").at_selector("a.two") end it "allows custom callbacks during inlining" do document = Roadie::Document.new <<-HTML Hello world HTML document.before_transformation = proc { |dom| dom.at_css("body")["class"] = "roadie" } document.after_transformation = proc { |dom| dom.at_css("span").remove } result = parse_html document.transform expect(result.at_css("body")["class"]).to eq("roadie") expect(result.at_css("span")).to be_nil end it "does not add whitespace between table cells" do document = Roadie::Document.new <<-HTML
One1
Two2
HTML result = document.transform expect(result).to include("One1") expect(result).to include("Two2") end it "doesn't inline styles in media queries with features" do document = Roadie::Document.new <<-HTML

HTML document.asset_providers = TestProvider.new( "/style.css" => <<-CSS .colorful { color: green; } @media screen and (max-width 600px) { .colorful { color: red; } } CSS ) result = parse_html document.transform expect(result).to have_styling("color" => "green").at_selector(".colorful") end it "puts non-inlineable media queries in the head" do document = Roadie::Document.new <<-HTML
HTML document.asset_providers = TestProvider.new( "/style.css" => <<-CSS .colorful { color: green; } @media screen and (max-width 800px) { .colorful { color: blue; } } @media screen, print and (max-width 800px) { .colorful { color: blue; } } CSS ) result = parse_html document.transform styles = result.at_css("html > head > style").text expected_result = <<-CSS @media screen and (max-width 800px) { .colorful{color:blue} } @media screen, print and (max-width 800px) { .colorful{color:blue} } CSS expected_result = expected_result.gsub(/\s+/, " ").strip actual_result = styles.gsub(/\s+/, " ").strip expect(actual_result).to eq(expected_result) end it "groups non-inlineable media queries in the head by default" do document = Roadie::Document.new <<-HTML
HTML document.asset_providers = TestProvider.new( "/style.css" => <<-CSS .colorful { color: green; } @media screen and (max-width 600px) { .colorful { color: red; width: 600px; } } @media screen and (max-width 600px) { .colorful-2 { color: red; width: 600px; } } CSS ) result = parse_html document.transform styles = result.at_css("html > head > style").text expected_result = <<-CSS @media screen and (max-width 600px) { .colorful{color:red;width:600px} .colorful-2{color:red;width:600px} } CSS expected_result = expected_result.gsub(/\s+/, " ").strip actual_result = styles.gsub(/\s+/, " ").strip expect(actual_result).to eq(expected_result) end it "adds XML declaration into XHTML with no serialization options prohibiting it" do document = Roadie::Document.new <<-HTML Greetings HTML document.mode = :xhtml document.serialization_options = 0 result = document.transform expect(result).to match(/\A<\?xml[^>]*?>/i) end it "does not add XML declaration into XHTML with serialization options prohibiting it" do document = Roadie::Document.new <<-HTML Greetings HTML document.mode = :xhtml document.serialization_options = Nokogiri::XML::Node::SaveOptions::NO_DECLARATION result = document.transform expect(result).not_to match(/\A<\?xml[^>]*?>/i) end describe "if merge_media_queries is set to false" do it "doesn't group non-inlineable media queries in the head" do document = Roadie::Document.new <<-HTML
HTML document.merge_media_queries = false document.asset_providers = TestProvider.new( "/style.css" => <<-CSS .colorful { color: green; } @media screen and (max-width 600px) { .colorful { color: red; width: 600px; } } @media screen and (max-width 600px) { .colorful-2 { color: red; width: 600px; } } CSS ) result = parse_html document.transform styles = result.at_css("html > head > style").text expected_result = <<-CSS @media screen and (max-width 600px) { .colorful{color:red;width:600px} } @media screen and (max-width 600px) { .colorful-2{color:red;width:600px} } CSS expected_result = expected_result.gsub(/\s+/, " ").strip actual_result = styles.gsub(/\s+/, " ").strip expect(actual_result).to eq(expected_result) end end end describe "on partial documents" do def parse_html(html) Nokogiri::HTML.fragment(html) end it "does not add structure" do html = "

Hello world!

".encode("Shift_JIS") document = Roadie::Document.new(html) result = document.transform_partial expect(result).to eq(html) end it "inlines given css" do document = Roadie::Document.new <<-HTML

Hello world!

Check out these awesome prices!

HTML document.add_css <<-CSS em { color: red; } h1 { text-align: center; } CSS result = parse_html document.transform_partial expect(result).to have_styling("text-align" => "center").at_selector("h1") expect(result).to have_styling("color" => "red").at_selector("p > em") end it "stores styles that cannot be inlined in a new
About us
HTML document.asset_providers = TestProvider.new( "/style.css" => "a { background: url(/assets/link-abcdef1234567890.png); }" ) document.url_options = {host: "myapp.com", scheme: "https", path: "rails/app/"} result = parse_html document.transform_partial expect(result.at_css("a")["href"]).to eq("https://myapp.com/rails/app/about_us") expect(result.at_css("img")["src"]).to eq( "https://myapp.com/rails/app/assets/about_us-abcdef1234567890.png" ) expect(result).to have_styling( "background" => 'url("https://myapp.com/rails/app/assets/bg-abcdef1234567890.png")' ).at_selector("div") expect(result).to have_styling( "background" => "url(https://myapp.com/rails/app/assets/link-abcdef1234567890.png)" ).at_selector("a") end it "allows custom callbacks during inlining" do document = Roadie::Document.new <<-HTML

Hello world

HTML document.before_transformation = proc { |dom| dom.at_css("p")["class"] = "roadie" } document.after_transformation = proc { |dom| dom.at_css("span").remove } result = parse_html document.transform_partial expect(result.at_css("p")["class"]).to eq("roadie") expect(result.at_css("span")).to be_nil end it "does not add whitespace between table cells" do document = Roadie::Document.new <<-HTML
One1
Two2
HTML result = document.transform_partial expect(result).to include("One1") expect(result).to include("Two2") end end end roadie-5.1.0/spec/lib/0000755000004100000410000000000014337365432014522 5ustar www-datawww-dataroadie-5.1.0/spec/lib/roadie/0000755000004100000410000000000014337365432015765 5ustar www-datawww-dataroadie-5.1.0/spec/lib/roadie/css_not_found_spec.rb0000644000004100000410000000160114337365432022165 0ustar www-datawww-data# frozen_string_literal: true require "spec_helper" module Roadie describe CssNotFound do it "is initialized with a name" do error = CssNotFound.new(css_name: "style.css") expect(error.css_name).to eq("style.css") expect(error.message).to eq('Could not find stylesheet "style.css"') end it "can be initialized with an extra message" do error = CssNotFound.new(css_name: "file.css", message: "directory is missing") expect(error.message).to eq( 'Could not find stylesheet "file.css": directory is missing' ) end it "shows information about used provider when given" do provider = double("Some cool provider") error = CssNotFound.new(css_name: "style.css", provider: provider) expect(error.message).to eq( %(Could not find stylesheet "style.css"\nUsed provider:\n#{provider}) ) end end end roadie-5.1.0/spec/lib/roadie/style_property_spec.rb0000644000004100000410000000350114337365432022427 0ustar www-datawww-data# frozen_string_literal: true require "spec_helper" module Roadie describe StyleProperty do it "is initialized with a property, value, if it is marked as important, and the specificity" do StyleProperty.new("color", "green", true, 45).tap do |declaration| expect(declaration.property).to eq("color") expect(declaration.value).to eq("green") expect(declaration).to be_important expect(declaration.specificity).to eq(45) end end describe "string representation" do it "is the property and the value joined with a colon" do expect(StyleProperty.new("color", "green", false, 1).to_s).to eq("color:green") expect(StyleProperty.new("font-size", "1.1em", false, 1).to_s).to eq("font-size:1.1em") end it "contains the !important flag when set" do expect(StyleProperty.new("color", "green", true, 1).to_s).to eq("color:green !important") end end describe "comparing" do def declaration(specificity, important = false) StyleProperty.new("color", "green", important, specificity) end it "compares on specificity" do expect(declaration(5)).to eq(declaration(5)) expect(declaration(4)).to be < declaration(5) expect(declaration(6)).to be > declaration(5) end context "with an important declaration" do it "is less than the important declaration regardless of the specificity" do expect(declaration(99, false)).to be < declaration(1, true) end it "compares like normal when both declarations are important" do expect(declaration(5, true)).to eq(declaration(5, true)) expect(declaration(4, true)).to be < declaration(5, true) expect(declaration(6, true)).to be > declaration(5, true) end end end end end roadie-5.1.0/spec/lib/roadie/url_rewriter_spec.rb0000644000004100000410000000651314337365432022056 0ustar www-datawww-data# frozen_string_literal: true require "spec_helper" require "shared_examples/url_rewriter" module Roadie describe UrlRewriter do let(:generator) { double("URL generator") } subject(:rewriter) { UrlRewriter.new(generator) } it_behaves_like "url rewriter" describe "transforming DOM trees" do def dom_document(html) Nokogiri::HTML.parse html end it "rewrites all a[href]" do expect(generator).to receive(:generate_url).with("some/path").and_return "http://foo.com/" dom = dom_document <<-HTML Some path HTML expect { rewriter.transform_dom dom }.to change { dom.at_css("a")["href"] }.to "http://foo.com/" end it "rewrites relative img[src]" do expect(generator).to receive(:generate_url).with("some/path.jpg").and_return "http://foo.com/image.jpg" dom = dom_document <<-HTML HTML expect { rewriter.transform_dom dom }.to change { dom.at_css("img")["src"] }.to "http://foo.com/image.jpg" end it "rewrites url() directives inside style attributes" do expect(generator).to receive(:generate_url).with("some/path.jpg").and_return "http://foo.com/image.jpg" dom = dom_document <<-HTML
HTML expect { rewriter.transform_dom dom }.to change { dom.at_css("div")["style"] }.to 'background-image: url("http://foo.com/image.jpg");' end it "skips elements with data-roadie-ignore attributes" do allow(generator).to receive(:generate_url).and_return("http://example.com") dom = dom_document <<-HTML Image
HTML rewriter.transform_dom dom expect(generator).not_to have_received(:generate_url) end end describe "transforming css" do it "rewrites all url() directives" do expect(generator).to receive(:generate_url).with("some/path.jpg").and_return "http://foo.com/image.jpg" css = "body { background: top url(some/path.jpg) #eee; }" transformed_css = rewriter.transform_css css expect(transformed_css).to eq "body { background: top url(http://foo.com/image.jpg) #eee; }" end it "correctly identifies URLs with single quotes" do expect(generator).to receive(:generate_url).with("images/foo.png").and_return "x" rewriter.transform_css "url('images/foo.png')" end it "correctly identifies URLs with double quotes" do expect(generator).to receive(:generate_url).with("images/foo.png").and_return "x" rewriter.transform_css 'url("images/foo.png")' end it "correctly identifies URLs with parenthesis inside them" do expect(generator).to receive(:generate_url).with("images/map_(large_(extra)).png").and_return "x" rewriter.transform_css "url(images/map_(large_(extra)).png)" end end end end roadie-5.1.0/spec/lib/roadie/null_url_rewriter_spec.rb0000644000004100000410000000073114337365432023104 0ustar www-datawww-data# frozen_string_literal: true require "spec_helper" require "shared_examples/url_rewriter" module Roadie describe NullUrlRewriter do let(:generator) { double "URL generator" } subject(:rewriter) { NullUrlRewriter.new(generator) } it_behaves_like "url rewriter" it "does nothing when transforming DOM" do dom = double "DOM tree" expect { NullUrlRewriter.new(generator).transform_dom dom }.to_not raise_error end end end roadie-5.1.0/spec/lib/roadie/test_provider_spec.rb0000644000004100000410000000177714337365432022231 0ustar www-datawww-data# frozen_string_literal: true require "spec_helper" require "roadie/rspec" describe TestProvider do subject(:provider) { TestProvider.new } it_behaves_like "roadie asset provider", valid_name: "existing.css", invalid_name: "invalid.css" do subject { TestProvider.new "existing.css" => "" } end it "finds styles from a predefined hash" do provider = TestProvider.new({ "foo.css" => "a { color: red; }", "bar.css" => "body { color: green; }" }) expect(provider.find_stylesheet("foo.css").to_s).not_to include("body") expect(provider.find_stylesheet("bar.css").to_s).to include("body") expect(provider.find_stylesheet("baz.css")).to be_nil end it "can have a default for missing entries" do provider = TestProvider.new({ "foo.css" => "a { color: red; }", :default => "body { color: green; }" }) expect(provider.find_stylesheet("foo.css").to_s).not_to include("body") expect(provider.find_stylesheet("bar.css").to_s).to include("body") end end roadie-5.1.0/spec/lib/roadie/style_attribute_builder_spec.rb0000644000004100000410000000262214337365432024257 0ustar www-datawww-data# frozen_string_literal: true require "spec_helper" module Roadie describe StyleAttributeBuilder do it "sorts the added properties" do builder = StyleAttributeBuilder.new builder << StyleProperty.new("color", "green", true, 1) builder << StyleProperty.new("font-size", "110%", false, 15) builder << StyleProperty.new("color", "red", false, 15) expect(builder.attribute_string).to eq "font-size:110%;color:red;color:green !important" end it "preserves the order of added attributes with the same specificity" do builder = StyleAttributeBuilder.new builder << StyleProperty.new("color", "pink", false, 50) builder << StyleProperty.new("color", "red", false, 50) builder << StyleProperty.new("color", "green", false, 50) # We need one different element to trigger the problem with Ruby's # unstable sort builder << StyleProperty.new("background", "white", false, 1) expect(builder.attribute_string).to eq "background:white;color:pink;color:red;color:green" end it "removes duplicate properties" do builder = StyleAttributeBuilder.new builder << StyleProperty.new("color", "pink", false, 10) builder << StyleProperty.new("color", "green", false, 20) builder << StyleProperty.new("color", "pink", false, 50) expect(builder.attribute_string).to eq "color:green;color:pink" end end end roadie-5.1.0/spec/lib/roadie/net_http_provider_spec.rb0000644000004100000410000001370414337365432023070 0ustar www-datawww-data# frozen_string_literal: true require "spec_helper" require "roadie/rspec" require "shared_examples/asset_provider" module Roadie describe NetHttpProvider do around do |example| WebMock.disable_net_connect! example.run WebMock.allow_net_connect! end url = "http://example.com/style.css" it_behaves_like( "roadie asset provider", valid_name: "http://example.com/green.css", invalid_name: "http://example.com/red.css" ) do before do stub_request(:get, "http://example.com/green.css").and_return(body: +"p { color: green; }") stub_request(:get, "http://example.com/red.css").and_return(status: 404, body: "Not here!") end end it "can download over HTTPS" do stub_request(:get, "https://example.com/style.css").and_return(body: +"p { color: green; }") expect { NetHttpProvider.new.find_stylesheet!("https://example.com/style.css") }.to_not raise_error end it "assumes HTTPS when given a scheme-less URL" do # Some people might re-use the same template as they use on a webpage, # and browsers support URLs without a scheme in them, replacing the # scheme with the current one. There's no "current" scheme when doing # asset inlining, but the scheme-less URL implies that there should exist # both a HTTP and a HTTPS endpoint. Let's take the secure one in that # case! stub_request(:get, "https://example.com/style.css").and_return(body: +"p { color: green; }") expect { NetHttpProvider.new.find_stylesheet!("//example.com/style.css") }.to_not raise_error end it "applies encoding from the response" do # Net::HTTP always returns the body string as a byte-encoded string # (US-ASCII). The headers will indicate what charset the client should # use when trying to make sense of these bytes. stub_request(:get, url).and_return( body: (+%(p::before { content: "l\xF6ve" })).force_encoding("US-ASCII"), headers: {"Content-Type" => "text/css;charset=ISO-8859-1"} ) # Seems like CssParser strips out the non-ascii character for some # reason. # stylesheet = NetHttpProvider.new.find_stylesheet!(url) # expect(stylesheet.to_s).to eq('p::before{content:"löve"}') allow(Stylesheet).to receive(:new).and_return(instance_double(Stylesheet)) NetHttpProvider.new.find_stylesheet!(url) expect(Stylesheet).to have_received(:new).with(url, 'p::before { content: "löve" }') end it "assumes UTF-8 encoding if server headers do not specify a charset" do stub_request(:get, url).and_return( body: (+%(p::before { content: "Åh nej" })).force_encoding("US-ASCII"), headers: {"Content-Type" => "text/css"} ) # Seems like CssParser strips out the non-ascii characters for some # reason. # stylesheet = NetHttpProvider.new.find_stylesheet!(url) # expect(stylesheet.to_s).to eq('p::before{content:"Åh nej"}') allow(Stylesheet).to receive(:new).and_return(instance_double(Stylesheet)) NetHttpProvider.new.find_stylesheet!(url) expect(Stylesheet).to have_received(:new).with(url, 'p::before { content: "Åh nej" }') end describe "error handling" do it "handles timeouts" do stub_request(:get, url).and_timeout expect { NetHttpProvider.new.find_stylesheet!(url) }.to raise_error CssNotFound, /timeout/i expect { NetHttpProvider.new.find_stylesheet(url) }.to_not raise_error end it "displays response code and beginning of message body" do stub_request(:get, url).and_return( status: 503, body: "Whoah there! Didn't you see we have a service window at this time? It's kind of disrespectful not to remember everything I tell you all the time!" ) expect { NetHttpProvider.new.find_stylesheet!(url) }.to raise_error CssNotFound, /503.*whoah/i end end describe "whitelist" do it "can have a whitelist of host names" do provider = NetHttpProvider.new(whitelist: ["example.com", "foo.bar"]) expect(provider.whitelist).to eq Set["example.com", "foo.bar"] end it "defaults to empty whitelist" do expect(NetHttpProvider.new.whitelist).to eq Set[] expect(NetHttpProvider.new(whitelist: nil).whitelist).to eq Set[] end it "will not download from other hosts if set" do provider = NetHttpProvider.new(whitelist: ["whitelisted.example.com"]) whitelisted_url = "http://whitelisted.example.com/style.css" other_url = "http://www.example.com/style.css" whitelisted_request = stub_request(:get, whitelisted_url).and_return(body: +"x") other_request = stub_request(:get, other_url).and_return(body: +"x") expect(provider.find_stylesheet(other_url)).to be_nil expect { provider.find_stylesheet!(other_url) }.to raise_error CssNotFound, /whitelist/ expect { expect(provider.find_stylesheet(whitelisted_url)).to_not be_nil provider.find_stylesheet!(whitelisted_url) }.to_not raise_error expect(whitelisted_request).to have_been_made.twice expect(other_request).to_not have_been_made end it "is displayed in the string representation" do expect(NetHttpProvider.new(whitelist: ["bar.baz"]).to_s).to include "bar.baz" end it "raises error when given invalid hostnames" do expect { NetHttpProvider.new(whitelist: [nil]) }.to raise_error(ArgumentError) expect { NetHttpProvider.new(whitelist: [""]) }.to raise_error(ArgumentError) expect { NetHttpProvider.new(whitelist: ["."]) }.to raise_error(ArgumentError) expect { NetHttpProvider.new(whitelist: ["http://foo.bar"]) }.to raise_error(ArgumentError) expect { NetHttpProvider.new(whitelist: ["foo/bar"]) }.to raise_error(ArgumentError) end end end end roadie-5.1.0/spec/lib/roadie/utils_spec.rb0000644000004100000410000000175714337365432020476 0ustar www-datawww-data# frozen_string_literal: true require "spec_helper" module Roadie describe Utils, "path_is_absolute?" do RSpec::Matchers.define :be_absolute do match { |path| Utils.path_is_absolute?(path) } end it "detects absolute HTTP URLs" do expect("http://example.com").to be_absolute expect("https://example.com").to be_absolute expect("https://example.com/path?foo=bar").to be_absolute end it "detects absolute URLs without schemes" do expect("//example.com").to be_absolute expect("//").to be_absolute end it "detects relative URLs without hosts" do expect("path/to/me").not_to be_absolute expect("/path/to/me").not_to be_absolute expect("../../path").not_to be_absolute expect("/").not_to be_absolute end end describe Utils, "warn" do it "passes the message on to Kernel.warn" do expect(Kernel).to receive(:warn).with("Roadie: Hello from specs") Utils.warn "Hello from specs" end end end roadie-5.1.0/spec/lib/roadie/url_generator_spec.rb0000644000004100000410000001516214337365432022201 0ustar www-datawww-data# frozen_string_literal: true require "spec_helper" module Roadie describe UrlGenerator do it "is initialized with URL options" do expect(UrlGenerator.new(host: "foo.com").url_options).to eq({host: "foo.com"}) end it "raises an argument error if no URL options are specified" do expect { UrlGenerator.new(nil) }.to raise_error ArgumentError, /url options/i end it "raises an argument error if no host is specified" do expect { UrlGenerator.new(port: 3000) }.to raise_error ArgumentError, /host/i end it "raises an argument error if unknown option is passed" do expect { UrlGenerator.new(host: "localhost", secret: true) }.to raise_error ArgumentError, /secret/ end describe "generating URLs" do def url(path, options = {}) UrlGenerator.new(options).generate_url(path) end it "uses the given host" do expect(url("/hello.jpg", host: "goats.com")).to eq("http://goats.com/hello.jpg") end it "uses the given port" do expect(url("/", host: "example.com", port: 1337)).to eq("http://example.com:1337/") end it "uses the given scheme" do expect(url("/", host: "example.com", scheme: "https")).to eq("https://example.com/") end it "regards :protocol as an alias for scheme" do expect(url("/", host: "example.com", protocol: "https")).to eq("https://example.com/") end it "strips extra characters from the scheme" do expect(url("/", host: "example.com", scheme: "https://")).to eq("https://example.com/") end it "uses the given path as a prefix" do expect(url("/my_file", host: "example.com", path: "/my_app")).to eq( "http://example.com/my_app/my_file" ) end it "returns the original URL if it is absolute" do expect(url("http://foo.com/", host: "bar.com")).to eq("http://foo.com/") end it "returns the original URL if it is just an anchor" do expect(url("#top", host: "bar.com")).to eq("#top") end it "returns the base URL for blank paths" do expect(url("", host: "foo.com")).to eq("http://foo.com") expect(url(nil, host: "foo.com")).to eq("http://foo.com") end it "raises an error on invalid path" do expect { url("://", host: "example.com") }.to raise_error InvalidUrlPath, %r{://} end it "accepts base paths without a slash in the beginning" do expect(url("/bar", host: "example.com", path: "foo")).to eq("http://example.com/foo/bar") expect(url("/bar/", host: "example.com", path: "foo/")).to eq("http://example.com/foo/bar/") end it "accepts input paths without a slash in the beginning" do expect(url("bar", host: "example.com", path: "/foo")).to eq("http://example.com/foo/bar") expect(url("bar", host: "example.com", path: "/foo/")).to eq("http://example.com/foo/bar") end it "does not touch data: URIs" do # We've had failures with longer data URIs, but I have not been able to # pinpoint where the problem is. I suspect a specific version of Ruby. # This test might not actually catch the real issues since I couldn't # get it red for the reported cases. # It was solved by being more relaxed when determining if a URI is # absolute or not. data_uri = "data:image/png;dead/beef+/==" expect(url(data_uri, host: "example.com")).to eq(data_uri) end it "does not touch custom schemes" do expect(url("myapp://", host: "example.com")).to eq("myapp://") end it "does not care if absolute URLs have parse errors" do # Pipe character is invalid inside URLs, but that does not stop a whole # lot of templating/emailing systems for using them as template # markers. expect(url("https://foo.com/%|MARKETING_TOKEN|%", host: "example.com")).to eq("https://foo.com/%|MARKETING_TOKEN|%") end # A lot of email clients do not support schemeless URLs (it's a HTML5 # feature) so we should add a scheme to them. context "on schemeless urls" do # Checking for host matches would be too complex, and it's not too hard # to assume that schemeless URLs to assets comes from a shared # configuration with a web page which uses HTTP and HTTPS in different # cases. That also means that we'd like to match the assets URLs with # whatever we want to link to, most likely. it "adds given scheme, even when host does not match" do result = url("//assets.myapp.com/foo.jpg", host: "example.com", scheme: "https") expect(result).to eq("https://assets.myapp.com/foo.jpg") end it "adds standard http: scheme when no scheme given" do result = url("//assets.myapp.com/foo.jpg", host: "example.com") expect(result).to eq("http://assets.myapp.com/foo.jpg") end it "adds scheme to invalid URLs" do result = url("//foo.com/%|TOKEN|%", scheme: "ftp", host: "example.com") expect(result).to eq("ftp://foo.com/%|TOKEN|%") end end end # URLs in resources that are not based inside the root requires that we may # specify a "custom base" to properly handle relative paths. Here's an # example: # # # / # # document.html # # images/ # # bg.png # # stylesheets/ # # my_style.css # # # stylesheets/my_style.css # body { background-image: url(../images/bg.png); } # # In this example, the stylesheet refers to /images/bg.png by using a # relative path from /stylesheets/. In order to understand these cases, we # need to specify where the "base" is in relation to the root. describe "generating URLs with custom base" do it "resolves relative paths" do generator = UrlGenerator.new(host: "foo.com") expect(generator.generate_url("../images/bg.png", "/stylesheets")).to eq("http://foo.com/images/bg.png") expect(generator.generate_url("../images/bg.png", "email/stylesheets")).to eq("http://foo.com/email/images/bg.png") expect(generator.generate_url("images/bg.png", "email/")).to eq("http://foo.com/email/images/bg.png") end it "does not use the base when presented with a root-based path" do generator = UrlGenerator.new(host: "foo.com") expect(generator.generate_url("/images/bg.png", "/stylesheets")).to eq("http://foo.com/images/bg.png") expect(generator.generate_url("/images/bg.png", "email/stylesheets")).to eq("http://foo.com/images/bg.png") end end end end roadie-5.1.0/spec/lib/roadie/inliner_spec.rb0000644000004100000410000002067114337365432020772 0ustar www-datawww-data# frozen_string_literal: true require "spec_helper" module Roadie describe Inliner do before { @stylesheet = "" } def use_css(css) @stylesheet = Stylesheet.new("example", css) end def rendering(html, stylesheet = @stylesheet) dom = Nokogiri::HTML.parse html Inliner.new([stylesheet], dom).inline dom end describe "inlining styles" do it "inlines simple attributes" do use_css "p { color: green }" expect(rendering("

")).to have_styling("color" => "green") end it "keeps multiple versions of the same property to support progressive enhancement" do # https://github.com/premailer/css_parser/issues/44 pending "css_parser issue #44" use_css "p { color: #eee; color: rgba(255, 255, 255, 0.9); }" expect(rendering("

")).to have_styling( [["color", "green"], ["color", "rgba(255, 255, 255, 0.9)"]] ) end it "de-duplicates identical styles" do use_css ' p { color: green; } .message { color: blue; } .positive { color: green; } ' expect(rendering('

')).to have_styling( [["color", "blue"], ["color", "green"]] ) end it "inlines browser-prefixed attributes" do use_css "p { -vendor-color: green }" expect(rendering("

")).to have_styling("-vendor-color" => "green") end it "inlines CSS3 attributes" do use_css "p { border-radius: 2px; }" expect(rendering("

")).to have_styling("border-radius" => "2px") end it "keeps the order of the styles that are inlined" do use_css "h1 { padding: 2px; margin: 5px; }" expect(rendering("

")).to have_styling([["padding", "2px"], ["margin", "5px"]]) end it "combines multiple selectors into one" do use_css 'p { color: green; } .tip { float: right; }' expect(rendering('

')).to have_styling([["color", "green"], ["float", "right"]]) end it "uses the attributes with the highest specificity when conflicts arises" do use_css ".safe { color: green; } p { color: red; }" expect(rendering('

')).to have_styling([["color", "red"], ["color", "green"]]) end it "sorts styles by specificity order" do use_css 'p { important: no; } #important { important: very; } .important { important: yes; }' expect(rendering('

')).to have_styling([ %w[important no], %w[important yes] ]) expect(rendering('

')).to have_styling([ %w[important no], %w[important yes], %w[important very] ]) end it "supports multiple selectors for the same rules" do use_css "p, a { color: green; }" rendering("

").tap do |document| expect(document).to have_styling("color" => "green").at_selector("p") expect(document).to have_styling("color" => "green").at_selector("a") end end it "keeps !important properties" do use_css "a { text-decoration: underline !important; } a.hard-to-spot { text-decoration: none; }" expect(rendering('')).to have_styling([ ["text-decoration", "none"], ["text-decoration", "underline !important"] ]) end it "combines with already present inline styles" do use_css "p { color: green }" expect(rendering('

')).to have_styling([["color", "green"], ["font-size", "1.1em"]]) end it "does not override inline styles" do use_css "p { text-transform: uppercase; color: red }" # The two color properties are kept to make css fallbacks work correctly expect(rendering('

')).to have_styling([ ["text-transform", "uppercase"], ["color", "red"], ["color", "green"] ]) end it "does not apply link and dynamic pseudo selectors" do use_css " p:active { color: red } p:focus { color: red } p:hover { color: red } p:link { color: red } p:target { color: red } p:visited { color: red } p.active { width: 100%; } " expect(rendering('

')).to have_styling("width" => "100%") end it "does not crash on any pseudo element selectors" do use_css " p.some-element { width: 100%; } p::some-element { color: red; } " expect(rendering('

')).to have_styling("width" => "100%") end it "warns on selectors that crash Nokogiri" do dom = Nokogiri::HTML.parse "

" stylesheet = Stylesheet.new "foo.css", "p[%^=foo] { color: red; }" inliner = Inliner.new([stylesheet], dom) expect(Utils).to receive(:warn).with( %(Cannot inline "p[%^=foo]" from "foo.css" stylesheet. If this is valid CSS, please report a bug.) ) inliner.inline end it "works with nth-child" do use_css " p { color: red; } p:nth-child(2n) { color: green; } " result = rendering("

") expect(result).to have_styling([["color", "red"]]).at_selector("p:first") expect(result).to have_styling([["color", "red"], ["color", "green"]]).at_selector("p:last") end context "with uninlinable selectors" do before do allow(Roadie::Utils).to receive(:warn) end it "puts them in a new HTML scanner = AssetScanner.new dom, normal_provider, external_provider stylesheets = scanner.find_css expect(stylesheets).to have(2).stylesheets expect(stylesheets[0].to_s).to include("green") expect(stylesheets[1].to_s).to include("red") expect(stylesheets.first.name).to eq("(inline)") end it "does not find any embedded stylesheets marked for ignoring" do dom = dom_document <<-HTML HTML scanner = AssetScanner.new dom, normal_provider, external_provider expect(scanner.find_css).to have(1).stylesheet end it "finds normal referenced stylesheets through the normal provider" do stylesheet = double "A stylesheet" expect(normal_provider).to receive(:find_stylesheet!).with("/some/url.css").and_return stylesheet dom = dom_fragment %() scanner = AssetScanner.new dom, normal_provider, external_provider expect(scanner.find_css).to eq([stylesheet]) end it "finds external referenced stylesheets through the external provider" do stylesheet = double "A stylesheet" external_provider = TestProvider.new expect(external_provider).to receive(:find_stylesheet!).with("//example.com/style.css").and_return stylesheet dom = dom_fragment %() scanner = AssetScanner.new dom, normal_provider, external_provider expect(scanner.find_css).to eq([stylesheet]) end it "ignores referenced print stylesheets" do dom = dom_fragment %() expect(normal_provider).not_to receive(:find_stylesheet!) scanner = AssetScanner.new dom, normal_provider, external_provider expect(scanner.find_css).to eq([]) end it "does not look for externally referenced stylesheets from empty ProviderList" do external_provider = ProviderList.empty dom = dom_fragment %() expect(external_provider).not_to receive(:find_stylesheet!) scanner = AssetScanner.new dom, normal_provider, external_provider expect(scanner.find_css).to eq([]) end it "does not look for ignored referenced stylesheets" do dom = dom_fragment %() expect(normal_provider).not_to receive(:find_stylesheet!) scanner = AssetScanner.new dom, normal_provider, external_provider expect(scanner.find_css).to eq([]) end it "ignores HTML comments and CDATA sections" do # TinyMCE posts invalid CSS. We support that just to be pragmatic. dom = dom_fragment %() scanner = AssetScanner.new dom, normal_provider, external_provider stylesheet = scanner.find_css.first expect(stylesheet.to_s).to include("green") expect(stylesheet.to_s).not_to include("!--") expect(stylesheet.to_s).not_to include("CDATA") end it "does not pick up scripts generating styles" do dom = dom_fragment <<-HTML HTML scanner = AssetScanner.new dom, normal_provider, external_provider expect(scanner.find_css).to eq([]) end end describe "extracting" do it "returns the stylesheets found, and removes them from the DOM" do dom = dom_document <<-HTML Hello world! HTML normal_provider = TestProvider.new "/some/url.css" => "body { color: green; }" scanner = AssetScanner.new dom, normal_provider, external_provider stylesheets = scanner.extract_css expect(stylesheets).to have(2).stylesheets expect(stylesheets[0].to_s).to include("span") expect(stylesheets[1].to_s).to include("body") expect(dom).to have_selector("html > head > title") expect(dom).to have_selector("html > body > style.totally-ignored") expect(dom).to have_selector("link.totally-ignored") expect(dom).to have_selector("link[media=print]") expect(dom).not_to have_selector("html > head > style") expect(dom).not_to have_selector("html > head > link[href='/some/url.css']") end it "removes external references if provider is not empty" do dom = dom_document <<-HTML HTML external_provider = ProviderList.wrap(NullProvider.new) scanner = AssetScanner.new dom, normal_provider, external_provider stylesheets = scanner.extract_css expect(stylesheets).to have(1).stylesheets expect(dom).to_not have_selector("link[href*=some]") expect(dom).to have_selector("link[href*=other]") end end end end roadie-5.1.0/spec/lib/roadie/markup_improver_spec.rb0000644000004100000410000000476314337365432022560 0ustar www-datawww-data# frozen_string_literal: true require "spec_helper" module Roadie describe MarkupImprover do def improve(html) dom = Nokogiri::HTML.parse html MarkupImprover.new(dom, html).improve dom end describe "automatic doctype" do it "inserts a HTML5 doctype if no doctype is present" do expect(improve("").internal_subset.to_xml).to eq("") end it "does not insert duplicate doctypes" do html = improve("").to_html expect(html.scan("DOCTYPE").size).to eq(1) end it "leaves other doctypes alone" do dtd = "" html = "#{dtd}" expect(improve(html).internal_subset.to_xml.strip).to eq(dtd) end end describe "basic HTML structure" do it "inserts a element as the root" do expect(improve("")).to have_selector("html") expect(improve("

Hey!

")).to have_selector("html h1") expect(improve("").css("html").size).to eq(1) end it "inserts if not present" do expect(improve("")).to have_selector("html > head + body") expect(improve("")).to have_selector("html > head") expect(improve("Foo")).to have_selector("html > head") expect(improve("").css("head").size).to eq(1) end it "inserts if not present" do expect(improve("

Hey!

")).to have_selector("html > body > h1") expect(improve("

Hey!

")).to have_selector("html > body > h1") expect(improve("

Hey!

").css("body").size).to eq(1) end end describe "charset declaration" do it "is inserted if missing" do dom = improve("") expect(dom).to have_selector("head meta") meta = dom.at_css("head meta") expect(meta["http-equiv"]).to eq("Content-Type") expect(meta["content"]).to eq("text/html; charset=UTF-8") end it "is left alone when predefined" do expect(improve(<<-HTML).xpath("//meta")).to have(1).item HTML end end end end roadie-5.1.0/spec/lib/roadie/selector_spec.rb0000644000004100000410000000346414337365432021153 0ustar www-datawww-data# frozen_string_literal: true require "spec_helper" module Roadie describe Selector do it "can be coerced into String" do expect("I love " + Selector.new("html")).to eq("I love html") end it "can be inlined when simple" do expect(Selector.new("html body #main p.class")).to be_inlinable end it "cannot be inlined when containing pseudo functions" do %w[ p:active p:focus p:hover p:link p:target p:visited p:-ms-input-placeholder p:-moz-placeholder p:before p:after p:enabled p:disabled p:checked p:host ].each do |bad_selector| expect(Selector.new(bad_selector)).not_to be_inlinable end expect(Selector.new("p.active")).to be_inlinable end it "cannot be inlined when containing pseudo elements" do expect(Selector.new("p::some-element")).not_to be_inlinable end it "cannot be inlined when selector is an at-rule" do expect(Selector.new("@keyframes progress-bar-stripes")).not_to be_inlinable end it "has a calculated specificity" do selector = "html p.active.nice #main.deep-selector" expect(Selector.new(selector).specificity).to eq(CssParser.calculate_specificity(selector)) end it "can be told about the specificity at initialization" do selector = "html p.active.nice #main.deep-selector" expect(Selector.new(selector, 1337).specificity).to eq(1337) end it "is equal to other selectors when they match the same things" do expect(Selector.new("foo")).to eq(Selector.new("foo ")) expect(Selector.new("foo")).not_to eq("foo") end it "strips the given selector" do expect(Selector.new(" foo \n").to_s).to eq(Selector.new("foo").to_s) end end end roadie-5.1.0/spec/lib/roadie/cached_provider_spec.rb0000644000004100000410000000304414337365432022446 0ustar www-datawww-data# frozen_string_literal: true require "spec_helper" require "roadie/rspec" require "shared_examples/asset_provider" module Roadie describe CachedProvider do let(:upstream) { TestProvider.new("good.css" => "body { color: green; }") } let(:cache) { {} } subject(:provider) { CachedProvider.new(upstream, cache) } it_behaves_like "roadie asset provider", valid_name: "good.css", invalid_name: "bad.css" it "stores retrieved stylesheets in the cache" do found = nil expect { found = provider.find_stylesheet("good.css") }.to change(cache, :keys).to(["good.css"]) expect(cache["good.css"]).to eq found end it "reads from the cache first" do found = upstream.find_stylesheet!("good.css") cache["good.css"] = found expect(upstream).to_not receive(:find_stylesheet) expect(provider.find_stylesheet("good.css")).to eq found expect(provider.find_stylesheet!("good.css")).to eq found end it "stores failed lookups in the cache" do expect { provider.find_stylesheet("foo.css") }.to change(cache, :keys).to(["foo.css"]) expect(cache["foo.css"]).to be_nil end it "stores failed lookups even when raising errors" do expect { provider.find_stylesheet!("bar.css") }.to raise_error CssNotFound expect(cache.keys).to include "bar.css" expect(cache["bar.css"]).to be_nil end it "defaults to a hash for cache storage" do expect(CachedProvider.new(upstream).cache).to be_kind_of Hash end end end roadie-5.1.0/spec/lib/roadie/document_spec.rb0000644000004100000410000002065314337365432021150 0ustar www-datawww-data# frozen_string_literal: true require "spec_helper" module Roadie describe Document do sample_html = "

Hello world!

" subject(:document) { described_class.new sample_html } it "is initialized with HTML" do doc = Document.new "" expect(doc.html).to eq("") end it "has an accessor for URL options" do document.url_options = {host: "foo.bar"} expect(document.url_options).to eq({host: "foo.bar"}) end it "has an accessor for serialization options" do serialization_options = Nokogiri::XML::Node::SaveOptions::FORMAT | Nokogiri::XML::Node::SaveOptions::NO_EMPTY_TAGS document.serialization_options = serialization_options expect(document.serialization_options).to eq(serialization_options) document.serialization_options = nil expect(document.serialization_options).to eq(0) end it "has a setting for keeping uninlinable styles" do expect(document.keep_uninlinable_css).to be true document.keep_uninlinable_css = false expect(document.keep_uninlinable_css).to be false end it "has a ProviderList for normal and external providers" do expect(document.asset_providers).to be_instance_of(ProviderList) expect(document.external_asset_providers).to be_instance_of(ProviderList) end it "defaults to having just a FilesystemProvider in the normal provider list" do expect(document).to have(1).asset_providers expect(document).to have(0).external_asset_providers provider = document.asset_providers.first expect(provider).to be_instance_of(FilesystemProvider) end it "defaults to HTML mode" do expect(document.mode).to eq(:html) end it "allows changes to the normal asset providers" do other_provider = double "Other proider" old_list = document.asset_providers document.asset_providers = [other_provider] expect(document.asset_providers).to be_instance_of(ProviderList) expect(document.asset_providers.each.to_a).to eq([other_provider]) document.asset_providers = old_list expect(document.asset_providers).to eq(old_list) end it "allows changes to the external asset providers" do other_provider = double "Other proider" old_list = document.external_asset_providers document.external_asset_providers = [other_provider] expect(document.external_asset_providers).to be_instance_of(ProviderList) expect(document.external_asset_providers.each.to_a).to eq([other_provider]) document.external_asset_providers = old_list expect(document.external_asset_providers).to eq(old_list) end it "allows changes to the mode setting" do document.mode = :xhtml expect(document.mode).to eq(:xhtml) document.mode = :html expect(document.mode).to eq(:html) document.mode = :xml expect(document.mode).to eq(:xml) end it "does not allow unknown modes" do expect { document.mode = :other }.to raise_error(ArgumentError, /:other/) end it "can store callbacks for inlining" do callable = double "Callable" document.before_transformation = callable document.after_transformation = callable expect(document.before_transformation).to eq(callable) expect(document.after_transformation).to eq(callable) end describe "transforming" do it "runs the before and after callbacks" do document = Document.new "" before = -> {} after = -> {} document.before_transformation = before document.after_transformation = after expect(before).to receive(:call).with(instance_of(Nokogiri::HTML::Document), document).ordered expect(Inliner).to receive(:new).ordered.and_return double.as_null_object expect(after).to receive(:call).with(instance_of(Nokogiri::HTML::Document), document).ordered document.transform end context "in HTML mode" do it "does not escape curly braces" do document = Document.new "Hello" document.mode = :xhtml expect(document.transform).to include("{{hello}}") end end context "in XML mode" do it "doesn't replace empty tags with self-closed ones" do document = Document.new "" document.mode = :xml expect(document.transform_partial).to end_with("") end it "does not escape curly braces" do document = Document.new "Hello" document.mode = :xml expect(document.transform_partial).to include("{{hello}}") end end end describe "partial transforming" do it "runs the before and after callbacks" do document = Document.new "

" before = -> {} after = -> {} document.before_transformation = before document.after_transformation = after expect(before).to receive(:call).with( instance_of(Nokogiri::HTML::DocumentFragment), document ).ordered expect(Inliner).to receive(:new).ordered.and_return double.as_null_object expect(after).to receive(:call).with( instance_of(Nokogiri::HTML::DocumentFragment), document ).ordered document.transform_partial end context "in HTML mode" do it "does not escape curly braces" do document = Document.new "Hello" document.mode = :xhtml expect(document.transform_partial).to include("{{hello}}") end end context "in XML mode" do it "doesn't replace empty tags with self-closed ones" do document = Document.new "" document.mode = :xml expect(document.transform_partial).to end_with("") end it "does not escape curly braces" do document = Document.new "Hello" document.mode = :xml expect(document.transform_partial).to include("{{hello}}") end end end end describe Document, "(integration)" do it "can transform the document" do document = Document.new <<-HTML Greetings

Hello, world!

HTML document.add_css "p { color: green; }" result = Nokogiri::HTML.parse document.transform expect(result).to have_selector("html > head > title") expect(result.at_css("title").text).to eq("Greetings") expect(result).to have_selector("html > body > p") paragraph = result.at_css("p") expect(paragraph.text).to eq("Hello, world!") expect(paragraph.to_xml).to eq('

Hello, world!

') end it "extracts styles from the HTML" do document = Document.new <<-HTML Greetings

Hello, world!

HTML document.asset_providers = TestProvider.new({ "/sample.css" => "p { color: red; text-align: right; }" }) document.add_css "p { color: green; text-size: 2em; }" result = Nokogiri::HTML.parse document.transform expect(result).to have_styling([ %w[color red], %w[text-align right], %w[color green], %w[text-size 2em] ]).at_selector("p") end it "removes data-roadie-ignore markers" do document = Document.new <<-HTML Hello world! HTML result = Nokogiri::HTML.parse document.transform expect(result).to have_selector("body > a > span") expect(result).not_to have_selector("[data-roadie-ignore]") end end end roadie-5.1.0/spec/shared_examples/0000755000004100000410000000000014337365432017120 5ustar www-datawww-dataroadie-5.1.0/spec/shared_examples/asset_provider.rb0000644000004100000410000000061614337365432022501 0ustar www-datawww-data# frozen_string_literal: true shared_examples_for "asset provider role" do it "responds to #find_stylesheet" do expect(subject).to respond_to(:find_stylesheet) expect(subject.method(:find_stylesheet).arity).to eq(1) end it "responds to #find_stylesheet!" do expect(subject).to respond_to(:find_stylesheet!) expect(subject.method(:find_stylesheet!).arity).to eq(1) end end roadie-5.1.0/spec/shared_examples/url_rewriter.rb0000644000004100000410000000137314337365432022176 0ustar www-datawww-data# frozen_string_literal: true shared_examples_for "url rewriter" do it "is constructed with a generator" do generator = double "URL generator" expect { described_class.new(generator) }.to_not raise_error end it "has a #transform_dom(dom) method that returns the modified string" do expect(subject).to respond_to(:transform_dom) expect(subject.method(:transform_dom).arity).to eq(1) dom = Nokogiri::HTML.parse "" expect(subject.transform_dom(dom)).to be_nil end it "has a #transform_css(css) method that returns the modified string" do expect(subject).to respond_to(:transform_css) expect(subject.method(:transform_css).arity).to eq(1) expect(subject.transform_css("")).to eq("") end end roadie-5.1.0/spec/spec_helper.rb0000644000004100000410000000065614337365432016601 0ustar www-datawww-data# frozen_string_literal: true require "rspec/collection_matchers" require "webmock/rspec" if ENV["CI"] require "simplecov" SimpleCov.start require "codecov" SimpleCov.formatter = SimpleCov::Formatter::Codecov end $: << File.dirname(__FILE__) + "/../lib" require "roadie" RSpec.configure do |config| config.run_all_when_everything_filtered = true end Dir["./spec/support/**/*.rb"].sort.each { |file| require file } roadie-5.1.0/spec/support/0000755000004100000410000000000014337365432015470 5ustar www-datawww-dataroadie-5.1.0/spec/support/have_styling_matcher.rb0000644000004100000410000000266414337365432022224 0ustar www-datawww-data# frozen_string_literal: true RSpec::Matchers.define :have_styling do |rules| normalized_rules = StylingExpectation.new(rules) chain(:at_selector) { |selector| @selector = selector } match { |document| @selector ||= "body > *:first" normalized_rules == styles_at_selector(document) } description { "have styles #{normalized_rules.inspect} at selector #{@selector.inspect}" } failure_message { |document| "expected styles at #{@selector.inspect} to be:\n#{normalized_rules}\nbut was:\n#{styles_at_selector(document)}" } failure_message_when_negated { "expected styles at #{@selector.inspect} to not be:\n#{normalized_rules}" } def styles_at_selector(document) expect(document).to have_selector(@selector) StylingExpectation.new document.at_css(@selector)["style"] end end class StylingExpectation def initialize(styling) case styling when String then @rules = parse_rules(styling) when Array then @rules = styling when Hash then @rules = styling.to_a when nil then @rules = [] else fail "I don't understand #{styling.inspect}!" end end def ==(other) rules == other.rules end def to_s rules.to_s end protected attr_reader :rules private def parse_rules(css) css.split(";").map { |property| parse_property(property) } end def parse_property(property) rule, value = property.split(":", 2).map(&:strip) [rule, value] end end roadie-5.1.0/spec/support/have_attribute_matcher.rb0000644000004100000410000000156614337365432022536 0ustar www-datawww-data# frozen_string_literal: true RSpec::Matchers.define :have_attribute do |attribute| @selector = "body > *:first" chain :at_selector do |selector| @selector = selector end match do |document| name, expected = attribute.first expected == attribute(document, name) end description { "have attribute #{attribute.inspect} at selector #{@selector.inspect}" } failure_message do |document| name, expected = attribute.first "expected #{name} attribute at #{@selector.inspect} to be #{expected.inspect} but was #{attribute(document, name).inspect}" end failure_message_when_negated do |document| name, expected = attribute.first "expected #{name} attribute at #{@selector.inspect} to not be #{expected.inspect}" end def attribute(document, attribute_name) node = document.css(@selector).first node && node[attribute_name] end end roadie-5.1.0/spec/support/have_xpath_matcher.rb0000644000004100000410000000045114337365432021647 0ustar www-datawww-data# frozen_string_literal: true RSpec::Matchers.define :have_xpath do |xpath| match { |document| !document.xpath(xpath).empty? } failure_message { "expected document to have xpath #{xpath.inspect}" } failure_message_when_negated { "expected document to not have xpath #{xpath.inspect}" } end roadie-5.1.0/spec/support/have_selector_matcher.rb0000644000004100000410000000047414337365432022350 0ustar www-datawww-data# frozen_string_literal: true RSpec::Matchers.define :have_selector do |selector| match { |document| !document.css(selector).empty? } failure_message { "expected document to have selector #{selector.inspect}" } failure_message_when_negated { "expected document to not have selector #{selector.inspect}" } end roadie-5.1.0/spec/support/have_node_matcher.rb0000644000004100000410000000123614337365432021452 0ustar www-datawww-data# frozen_string_literal: true RSpec::Matchers.define :have_node do |selector| chain(:with_attributes) { |attributes| @attributes = attributes } match do |document| node = document.at_css(selector) if @attributes node && match_attributes(node.attributes) else node end end failure_message { "expected document to #{name_to_sentence}#{expected_to_sentence}" } failure_message_when_negated { "expected document to not #{name_to_sentence}#{expected_to_sentence}" } def match_attributes(node_attributes) attributes = node_attributes.map { |name, attribute| [name, attribute.value] }.to_h @attributes == attributes end end roadie-5.1.0/spec/support/test_provider.rb0000644000004100000410000000046114337365432020707 0ustar www-datawww-data# frozen_string_literal: true class TestProvider include Roadie::AssetProvider def initialize(files = {}) @files = files @default = files[:default] end def find_stylesheet(name) contents = @files.fetch(name, @default) Roadie::Stylesheet.new name, contents if contents end end roadie-5.1.0/.rubocop.yml0000644000004100000410000000012514337365432015272 0ustar www-datawww-dataAllCops: DisabledByDefault: true Style/FrozenStringLiteralComment: Enabled: trueroadie-5.1.0/.gitignore0000644000004100000410000000013314337365432015007 0ustar www-datawww-data.DS_Store .yardoc .rspec .ruby-version Gemfile.lock doc tmp pkg .bundle .history vendor/ roadie-5.1.0/LICENSE0000644000004100000410000000211714337365432014030 0ustar www-datawww-dataCopyright (c) 2009-2016 Magnus Bergmark, Jim Neath / Purify, and contributors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. roadie-5.1.0/.solargraph.yml0000644000004100000410000000033214337365432015763 0ustar www-datawww-data--- include: - "**/*.rb" exclude: - spec/**/* - test/**/* - vendor/**/* - ".bundle/**/*" require: [] domains: [] plugins: - solargraph-standardrb reporters: - standardrb require_paths: [] max_files: 5000 roadie-5.1.0/.autotest0000644000004100000410000000036414337365432014676 0ustar www-datawww-data# Override autotest default magic to rerun all tests every time a # change is detected on the file system. class Autotest def get_to_green begin rerun_all_tests wait_for_changes unless all_good end until all_good end endroadie-5.1.0/Rakefile0000644000004100000410000000032114337365432014463 0ustar www-datawww-data# frozen_string_literal: true require "bundler/setup" Bundler::GemHelper.install_tasks desc "Run specs" task :spec do sh "bundle exec rspec -f progress" end desc "Default: Run specs" task default: :spec roadie-5.1.0/lib/0000755000004100000410000000000014337365432013570 5ustar www-datawww-dataroadie-5.1.0/lib/roadie.rb0000644000004100000410000000137214337365432015363 0ustar www-datawww-data# frozen_string_literal: true module Roadie end require "roadie/version" require "roadie/errors" require "roadie/utils" require "roadie/deduplicator" require "roadie/stylesheet" require "roadie/selector" require "roadie/style_property" require "roadie/style_attribute_builder" require "roadie/style_block" require "roadie/asset_provider" require "roadie/provider_list" require "roadie/filesystem_provider" require "roadie/null_provider" require "roadie/net_http_provider" require "roadie/cached_provider" require "roadie/path_rewriter_provider" require "roadie/asset_scanner" require "roadie/markup_improver" require "roadie/url_generator" require "roadie/url_rewriter" require "roadie/null_url_rewriter" require "roadie/inliner" require "roadie/document" roadie-5.1.0/lib/roadie/0000755000004100000410000000000014337365432015033 5ustar www-datawww-dataroadie-5.1.0/lib/roadie/version.rb0000644000004100000410000000010514337365432017041 0ustar www-datawww-data# frozen_string_literal: true module Roadie VERSION = "5.1.0" end roadie-5.1.0/lib/roadie/url_generator.rb0000644000004100000410000001071514337365432020234 0ustar www-datawww-data# frozen_string_literal: true require "set" module Roadie # @api private # Class that handles URL generation # # URL generation is all about converting relative URLs into absolute URLS # according to the given options. It is written such as absolute URLs will # get passed right through, so all URLs could be passed through here. class UrlGenerator attr_reader :url_options # Create a new instance with the given URL options. # # Initializing without a host setting raises an error, as do unknown keys. # # @param [Hash] url_options # @option url_options [String] :host (required) # @option url_options [String, Integer] :port # @option url_options [String] :path root path # @option url_options [String] :scheme URL scheme ("http" is default) # @option url_options [String] :protocol alias for :scheme def initialize(url_options) unless url_options raise ArgumentError, "No URL options were specified" end unless url_options[:host] raise ArgumentError, "No :host was specified; options were: #{url_options.inspect}" end validate_options url_options @url_options = url_options @scheme = normalize_scheme(url_options[:scheme] || url_options[:protocol]) @root_uri = build_root_uri end # Generate an absolute URL from a relative URL. # # If the passed path is already an absolute URL or just an anchor # reference, it will be returned as-is. # If passed a blank path, the "root URL" will be returned. The root URL is # the URL that the {#url_options} would generate by themselves. # # An optional base can be specified. The base is another relative path from # the root that specifies an "offset" from which the path was found in. A # common use-case is to convert a relative path found in a stylesheet which # resides in a subdirectory. # # @example Normal conversions # generator = Roadie::UrlGenerator.new host: "foo.com", scheme: "https" # generator.generate_url("bar.html") # => "https://foo.com/bar.html" # generator.generate_url("/bar.html") # => "https://foo.com/bar.html" # generator.generate_url("") # => "https://foo.com" # # @example Conversions with a base # generator = Roadie::UrlGenerator.new host: "foo.com", scheme: "https" # generator.generate_url("../images/logo.png", "/css") # => "https://foo.com/images/logo.png" # generator.generate_url("../images/logo.png", "/assets/css") # => "https://foo.com/assets/images/logo.png" # # @param [String] base The base which the relative path comes from # @return [String] an absolute URL def generate_url(path, base = "/") return root_uri.to_s if path.nil? || path.empty? return path if path_is_anchor?(path) return add_scheme(path) if path_is_schemeless?(path) return path if Utils.path_is_absolute?(path) combine_segments(root_uri, base, path).to_s end protected attr_reader :root_uri, :scheme private def build_root_uri path = make_absolute url_options[:path] port = parse_port url_options[:port] URI::Generic.build( scheme: scheme, host: url_options[:host], port: port, path: path ) end def add_scheme(path) [scheme, path].join(":") end def combine_segments(root, base, path) new_path = apply_base(base, path) if root.path new_path = File.join(root.path, new_path) end root.merge(new_path) end def apply_base(base, path) if path[0] == "/" path else File.join(base, path) end end # Strip :// from any scheme, if present def normalize_scheme(scheme) return "http" unless scheme scheme.to_s[/^\w+/] end def parse_port(port) (port ? port.to_i : port) end def make_absolute(path) if path.nil? || path[0] == "/" path else "/#{path}" end end def path_is_schemeless?(path) path =~ %r{^//\w} end def path_is_anchor?(path) path.start_with? "#" end VALID_OPTIONS = Set[:host, :port, :path, :protocol, :scheme].freeze def validate_options(options) keys = Set.new(options.keys) unless keys.subset? VALID_OPTIONS raise ArgumentError, "Passed invalid options: #{(keys - VALID_OPTIONS).to_a}, " \ "valid options are: #{VALID_OPTIONS.to_a}" end end end end roadie-5.1.0/lib/roadie/deduplicator.rb0000644000004100000410000000173514337365432020045 0ustar www-datawww-data# frozen_string_literal: true module Roadie class Deduplicator def self.apply(input) new(input).apply end def initialize(input) @input = input @duplicates = false end def apply # Bail early for very small inputs input if input.size < 2 calculate_latest_occurance # Another early bail in case we never even have a duplicate value if has_duplicates? strip_out_duplicates else input end end private attr_reader :input, :latest_occurance def has_duplicates? @duplicates end def calculate_latest_occurance @latest_occurance = input.each_with_index.each_with_object({}) do |(value, index), map| @duplicates = true if map.has_key?(value) map[value] = index end end def strip_out_duplicates input.each_with_index.select { |value, index| latest_occurance[value] == index }.map(&:first) end end end roadie-5.1.0/lib/roadie/null_url_rewriter.rb0000644000004100000410000000056014337365432021140 0ustar www-datawww-data# frozen_string_literal: true module Roadie # @api private # Null Object for the URL rewriter role. # # Used whenever client does not pass any URL options and no URL rewriting # should take place. class NullUrlRewriter def initialize(generator = nil) end def transform_dom(dom) end def transform_css(css) css end end end roadie-5.1.0/lib/roadie/document.rb0000644000004100000410000001771014337365432017204 0ustar www-datawww-data# frozen_string_literal: true module Roadie # The main entry point for Roadie. A document represents a working unit and # is built with the input HTML and the configuration options you need. # # A Document must never be used from two threads at the same time. Reusing # Documents is discouraged. # # Stylesheets are added to the HTML from three different sources: # 1. Stylesheets inside the document ( +