diff --git a/.codeclimate.yml b/.codeclimate.yml index df67882..7561b07 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -1,4 +1,2 @@ languages: Ruby: true -exclude_paths: - - lib/penthouse/penthouse.js \ No newline at end of file diff --git a/.gitignore b/.gitignore index b36aa3d..dde38a7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ test/version_tmp tmp .DS_Store +/node_modules/ +.rspec_status +/npm-debug.log diff --git a/.rubocop.yml b/.rubocop.yml index 78a5571..3d4c7cc 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -6,7 +6,6 @@ AllCops: - 'spec/*_helper.rb' - 'Gemfile' - 'Rakefile' - - 'Vagrantfile' Documentation: Enabled: false \ No newline at end of file diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 8830607..04b6c11 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,18 +1,34 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2015-10-29 13:19:55 -0500 using RuboCop version 0.34.1. +# on 2017-12-29 15:04:25 +0000 using RuboCop version 0.52.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 3 -# Configuration parameters: AllowURI, URISchemes. -Metrics/LineLength: - Max: 91 +# Offense count: 1 +Metrics/AbcSize: + Max: 19 # Offense count: 1 -# Configuration parameters: Exclude. -Style/FileName: +# Configuration parameters: CountComments. +Metrics/MethodLength: + Max: 31 + +# Offense count: 1 +Naming/AccessorMethodName: + Exclude: + - 'spec/support/static_file_server.rb' + +# Offense count: 1 +# Configuration parameters: ExpectMatchingDefinition, Regex, IgnoreExecutableScripts, AllowedAcronyms. +# AllowedAcronyms: CLI, DSL, ACL, API, ASCII, CPU, CSS, DNS, EOF, GUID, HTML, HTTP, HTTPS, ID, IP, JSON, LHS, QPS, RAM, RHS, RPC, SLA, SMTP, SQL, SSH, TCP, TLS, TTL, UDP, UI, UID, UUID, URI, URL, UTF8, VM, XML, XMPP, XSRF, XSS +Naming/FileName: Exclude: - 'lib/critical-path-css-rails.rb' + +# Offense count: 4 +# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. +# URISchemes: http, https +Metrics/LineLength: + Max: 96 diff --git a/BACKLOG.md b/BACKLOG.md index 7f6b692..3713b97 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -1,12 +1,8 @@ # Backlog -## Tests -- Add a testing suite (preferably rspec) - ## Features - Allow the user to give a single route for a Controller#Show route, instead of hard coding every unique Resource#Show URL * Implementation should account for any route that allows variables/parameters in the URL - Error reporting during CSS generation (404, 500 errors, etc.) -- Allow the user to pass arguments to Penthouse.js, i.e. Viewport size, etc. For a list of the configurable options, please see [Penthouse](https://github.com/pocketjoso/penthouse) - Improve installation process, if possible - Improve implementation. Is their a better solution then using Rails.cache? \ No newline at end of file diff --git a/Gemfile b/Gemfile index 07d2b13..673bb11 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,21 @@ -# A sample Gemfile -source "https://rubygems.org" +source 'https://rubygems.org' -gem 'rubocop', require: false \ No newline at end of file +gemspec + +group :development, :test do + gem 'actionpack' + gem 'byebug', platform: [:ruby], require: false + gem 'rubocop', require: false + gem 'rspec-rails', '~> 3.8' + gem 'capybara', '~> 3.14' + gem 'pry-rails' +end + +# HACK: npm install on bundle +unless $npm_commands_hook_installed # rubocop:disable Style/GlobalVars + Gem.pre_install do |installer| + next true unless installer.spec.name == 'critical-path-css-rails' + require_relative './ext/npm/install' + end + $npm_commands_hook_installed = true # rubocop:disable Style/GlobalVars +end diff --git a/README.md b/README.md index 836567d..7d692f9 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,32 @@ Only load the CSS you need for the initial viewport in Rails! -This gem give you the ability to load only the CSS you *need* on an initial page view. This gives you blazin' fast rending as there's no initial network call to grab your application's CSS. +This gem gives you the ability to load only the CSS you *need* on an initial page view. This gives you blazin' fast rending as there's no initial network call to grab your application's CSS. This gem assumes that you'll load the rest of the CSS asyncronously. At the moment, the suggested way is to use the [loadcss-rails](https://github.com/michael-misshore/loadcss-rails) gem. -This gem uses [PhantomJS](https://github.com/colszowka/phantomjs-gem) and [Penthouse](https://github.com/pocketjoso/penthouse) to generate the critical CSS. +This gem uses [Penthouse](https://github.com/pocketjoso/penthouse) to generate the critical CSS. +## Dependency Requirements for / Upgrading to the Latest Release + +### For 1.0.0 or later +To maintain the latest version of Penthouse, this gem depends on NodeJS and NVM to be installed on the system. + +### For 2.0.0 or later +This gem may require additional packages to be installed to run Chrome headless. Per the Penthouse documentation, this may be all you need: + +``` +sudo apt-get install libnss3 +``` + +However, more packages may need to be installed depending on your OS distribution which can be found via [this answer](https://github.com/GoogleChrome/puppeteer/issues/404#issuecomment-323555784) ## Installation -Add `critical-path-css-rails` to your Gemfile: +After reviewing the dependency requirements, add `critical-path-css-rails` to your Gemfile: ``` -gem 'critical-path-css-rails', '~> 0.2.0' +gem 'critical-path-css-rails', '~> 3.1.0' ``` Download and install by running: @@ -31,7 +44,7 @@ rails generate critical_path_css:install The generator adds the following files: -* `config/critical_path_css.yml` +* `config/critical_path_css.yml` **Note:** This file supports ERB. * `lib/tasks/critical_path_css.rake` @@ -39,8 +52,11 @@ The generator adds the following files: First, you'll need to configue a few things in the YAML file: `config/critical_path_css.yml` +**Note** that `manifest_name`, `css_path`, `css_paths` are all **mutually exclusive**; if using `css_path`, configuration for `manifest_name` AND `css_paths` should be omitted. + * `manifest_name`: If you're using the asset pipeline, add the manifest name. * `css_path`: If you're not using the asset pipeline, you'll need to define the path to the application's main CSS. The gem assumes your CSS lives in `RAILS_ROOT/public`. If your main CSS file is in `RAILS_ROOT/public/assets/main.css`, you would set the variable to `/assets/main.css`. +* `css_paths`: If you have the need to specify multiple CSS source files, you can do so with `css_paths`. When using this option, a separate CSS path must be specified for each route, and they will be matched based on the order specified (the first CSS path will be applied to the first route, the second CSS path to the second route, etc). * `routes`: List the routes that you would like to generate the critical CSS for. (i.e. /resources, /resources/show/1, etc.) * `base_url`: Add your application's URL for the necessary environments. @@ -61,39 +77,97 @@ To load the generated critical CSS into your layout, in the head tag, insert: ```HTML+ERB ``` -A simple example using loadcss-rails looks like: +A simple example using [loadcss-rails](https://github.com/michael-misshore/loadcss-rails) looks like: ```HTML+ERB + ``` +### Route-level Control of CSS Generation and Removal + +CriticalPathCss exposes some methods to give the user more control over the generation of Critical CSS and managment of the CSS cache: + +``` ruby +CriticalPathCss.generate route # Generates the critical path CSS for the given route (relative path) + +CriticalPathCss.generate_all # Generates critical CSS for all routes in critical_path_css.yml + +CriticalPathCss.clear route # Removes the CSS for the given route from the cache + +CriticalPathCss.clear_matched routes # Removes the CSS for the matched routes from the cache +``` + +NOTE: The `clear_matched` method will not work with Memcached due to the latter's incompatibility with Rails' `delete_matched` method. We recommend using an alternative cache such as [Redis](https://github.com/redis-store/redis-rails). + +In addition to the `critical_path_css:generate` rake task described above, you also have access to task which clears the CSS cache: + +``` +rake critical_path_css:clear_all +``` +NOTE: The `critical_path_css:clear_all` rake task may need to be customized to suit your particular cache implementation. + +Careful use of these methods allows the developer to generate critical path CSS dynamically within the app. The user should strongly consider using a [background job](http://edgeguides.rubyonrails.org/active_job_basics.html) when generating CSS in order to avoid tying up a rails thread. The `generate` method will send a GET request to your server which could cause infinite recursion if the developer is not careful. + +A user can use these methods to [dynamically generate critical path CSS](https://gist.github.com/taranda/1597e97ccf24c978b59aef9249666c77) without using the `rake critical_path_css:generate` rake task and without hardcoding the application's routes into `config/critical_path_css.yml`. See [this Gist](https://gist.github.com/taranda/1597e97ccf24c978b59aef9249666c77) for an example of such an implementation. + +## Upgrading from a version earlier than 0.3.0 + +The latest version of Critcal Path CSS Rails changes the functionality of the `generate` method. In past versions, +`generate` would produce CSS for all of the routes listed in `config/critical_path_css.yml`. This functionality has been replaced by the `generate_all` method, and `generate` will only produce CSS for one route. + +Developers upgrading from versions prior to 0.3.0 will need to replace `CriticalPathCss:generate` with `CriticalPathCss:generate_all` throughout their codebase. One file that will need updating is `lib/tasks/critical_path_css.rake`. Users can upgrade this file automatically by running: + +``` prompt +rails generate critical_path_css:install +``` + +Answer 'Y' when prompted to overwrite `critical_path_css.rake`. However, overwriting `critical_path_css.yml` is not recommended nor necessary. + + +## Testing / Development + +This gem is to be tested inside of docker/docker-compose. [Combustion](https://github.com/pat/combustion), alongside rspec-rails and capybara, are the primary components for testing. To run the test, you'll need to have [Docker](https://docs.docker.com/engine/installation) installed. Once installed, run the following commands in the gem's root to build, run, and shell into the docker container. + +```Bash + docker-compose build + docker-compose up -d + docker exec -it $(cat app_container_name) /bin/bash +``` + +Once shell'd in, run `bundle exec rspec spec` to run the test. The test rails app lives in `spec/internal`, and it can be viewed locally at `http://localhost:9292/` + +If you encounter Chromium errors trying to run the tests, installing [Puppeteer](https://github.com/GoogleChrome/puppeteer) might help. + +```Bash + npm install puppeteer +``` + ## Versions The critical-path-css-rails gem follows these version guidelines: ``` -patch version bump = updates to critical-path-css-rails and patch-level updates to Penthouse and PhantomJS -minor version bump = minor-level updates to critical-path-css-rails, Penthouse, and PhantomJS -major version bump = major-level updates to critical-path-css-rails, Penthouse, PhantomJS, and updates to Rails which may be backwards-incompatible +patch version bump = updates to critical-path-css-rails and patch-level updates to Penthouse +minor version bump = minor-level updates to critical-path-css-rails and Penthouse +major version bump = major-level updates to critical-path-css-rails, Penthouse, and updates to Rails which may be backwards-incompatible ``` ## Contributing -Feel free to open an issue ticket if you find something that could be improved. A couple notes: - -* If the Penthouse.js script is outdated (i.e. maybe a new version of Penthouse.js was released yesterday), feel free to open an issue and prod us to get that thing updated. However, for security reasons, we won't be accepting pull requests with updated Penthouse.js script. +Feel free to open an issue ticket if you find something that could be improved. -Copyright Mudbug Media and Michael Misshore, released under the MIT License. \ No newline at end of file +Copyright Mudbug Media and Michael Misshore, released under the MIT License. diff --git a/app_container_name b/app_container_name new file mode 100644 index 0000000..532a712 --- /dev/null +++ b/app_container_name @@ -0,0 +1 @@ +criticalpathcss_ruby \ No newline at end of file diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..4a1c95b --- /dev/null +++ b/config.ru @@ -0,0 +1,6 @@ +require 'rubygems' +require 'bundler' +require 'combustion' + +Combustion.initialize! :action_controller, :action_view +run Combustion::Application diff --git a/critical-path-css-rails.gemspec b/critical-path-css-rails.gemspec index 60dfa4d..c519083 100644 --- a/critical-path-css-rails.gemspec +++ b/critical-path-css-rails.gemspec @@ -1,18 +1,21 @@ require File.expand_path('../lib/critical_path_css/rails/version', __FILE__) -Gem::Specification.new do |s| - s.name = 'critical-path-css-rails' - s.version = CriticalPathCSS::Rails::VERSION - s.platform = Gem::Platform::RUBY - s.authors = ['Michael Misshore'] - s.email = 'mmisshore@gmail.com' - s.summary = 'Critical Path CSS for Rails!' - s.description = 'Only load the CSS you need for the initial viewport in Rails!' - s.license = 'MIT' +Gem::Specification.new do |gem| + gem.name = 'critical-path-css-rails' + gem.version = CriticalPathCSS::Rails::VERSION + gem.platform = Gem::Platform::RUBY + gem.authors = ['Michael Misshore'] + gem.email = 'mmisshore@gmail.com' + gem.summary = 'Critical Path CSS for Rails!' + gem.description = 'Only load the CSS you need for the initial viewport in Rails!' + gem.license = 'MIT' + gem.homepage = 'https://rubygems.org/gems/critical-path-css-rails' - s.add_runtime_dependency 'phantomjs', ['~> 1.9'] + gem.files = `git ls-files`.split("\n") + gem.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } + gem.require_path = 'lib' - s.files = `git ls-files`.split("\n") - s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } - s.require_path = 'lib' + gem.add_development_dependency 'combustion', '~> 1.1', '>= 1.1.0' + + gem.extensions = ['ext/npm/extconf.rb'] end diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..da22674 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +version: '2' +services: + ruby: + build: + context: . + dockerfile: docker/ruby/Dockerfile + ports: + - 9292:9292 + volumes: + - .:/app:rw + volumes_from: + - data + env_file: docker/ruby/.env + container_name: criticalpathcss_ruby + data: + build: + context: . + dockerfile: docker/ruby/Dockerfile + volumes: + - /gems + command: "true" \ No newline at end of file diff --git a/docker/ruby/.env b/docker/ruby/.env new file mode 100644 index 0000000..336ada1 --- /dev/null +++ b/docker/ruby/.env @@ -0,0 +1 @@ +RAILS_ENV=development diff --git a/docker/ruby/Dockerfile b/docker/ruby/Dockerfile new file mode 100644 index 0000000..3404a71 --- /dev/null +++ b/docker/ruby/Dockerfile @@ -0,0 +1,22 @@ +FROM ruby:2.5.0 + +# Install Dependencies +RUN curl -sL https://deb.nodesource.com/setup_8.x | bash - +RUN apt-get update && apt-get install -y build-essential libpq-dev nodejs npm + +# Install Penthouse JS Dependencies +RUN apt-get install -y libpangocairo-1.0-0 libx11-xcb1 libxcomposite1 libxcursor1 libxdamage1 libxi6 libxtst6 libnss3 libcups2 libxss1 libxrandr2 libgconf2-4 libasound2 libatk1.0-0 libgtk-3-0 + +# Configure Node/NPM +RUN npm cache clean -f +RUN npm install -g n +RUN n 10.15.1 +RUN ln -sf /usr/local/n/versions/node/10.15.1/bin/node /usr/bin/nodejs + +ENV BUNDLE_PATH /gems + +WORKDIR /app + +COPY docker/ruby/startup.dev /usr/local/bin/startup +RUN chmod 755 /usr/local/bin/startup +CMD "/usr/local/bin/startup" \ No newline at end of file diff --git a/docker/ruby/startup.dev b/docker/ruby/startup.dev new file mode 100644 index 0000000..c671745 --- /dev/null +++ b/docker/ruby/startup.dev @@ -0,0 +1,6 @@ +#!/bin/bash + +bundle check || bundle install +bundle update critical-path-css-rails + +bundle exec rackup --host 0.0.0.0 diff --git a/ext/npm/extconf.rb b/ext/npm/extconf.rb new file mode 100644 index 0000000..449c2c7 --- /dev/null +++ b/ext/npm/extconf.rb @@ -0,0 +1,2 @@ +File.write 'Makefile', + "make:\n\t\ninstall:\n\truby install.rb\nclean:\n\t\n" diff --git a/ext/npm/install.rb b/ext/npm/install.rb new file mode 100644 index 0000000..793ac21 --- /dev/null +++ b/ext/npm/install.rb @@ -0,0 +1,4 @@ +require_relative '../../lib/npm_commands' + +NpmCommands.new.install('--production', '.') || + raise('Error while installing npm dependencies') diff --git a/lib/config/critical_path_css.yml b/lib/config/critical_path_css.yml index e916570..5317754 100644 --- a/lib/config/critical_path_css.yml +++ b/lib/config/critical_path_css.yml @@ -3,6 +3,9 @@ defaults: &defaults manifest_name: application # Else provide the relative path of your CSS file from the /public directory # css_path: /path/to/css/from/public/main.css + # Or provide a separate path for each route + # css_paths: + # - /path/to/css/from/public/main.css routes: - / diff --git a/lib/critical-path-css-rails.rb b/lib/critical-path-css-rails.rb index 4e8bbaf..e361d3a 100644 --- a/lib/critical-path-css-rails.rb +++ b/lib/critical-path-css-rails.rb @@ -1,15 +1,37 @@ +require 'critical_path_css/configuration' +require 'critical_path_css/css_fetcher' +require 'critical_path_css/rails/config_loader' + module CriticalPathCss - require 'critical_path_css/css_fetcher' + CACHE_NAMESPACE = 'critical-path-css'.freeze - CACHE_NAMESPACE = 'critical-path-css' + def self.generate(route) + ::Rails.cache.write(route, fetcher.fetch_route(route), namespace: CACHE_NAMESPACE, expires_in: nil) + end - def self.generate - CssFetcher.new.fetch.each do |route, css| - Rails.cache.write(route, css, namespace: CACHE_NAMESPACE) + def self.generate_all + fetcher.fetch.each do |route, css| + ::Rails.cache.write(route, css, namespace: CACHE_NAMESPACE, expires_in: nil) end end + def self.clear(route) + ::Rails.cache.delete(route, namespace: CACHE_NAMESPACE) + end + + def self.clear_matched(routes) + ::Rails.cache.delete_matched(routes, namespace: CACHE_NAMESPACE) + end + def self.fetch(route) - Rails.cache.read(route, namespace: CACHE_NAMESPACE) || '' + ::Rails.cache.read(route, namespace: CACHE_NAMESPACE) || '' + end + + def self.fetcher + @fetcher ||= CssFetcher.new(Configuration.new(config_loader.config)) + end + + def self.config_loader + @config_loader ||= CriticalPathCss::Rails::ConfigLoader.new end end diff --git a/lib/critical_path_css/configuration.rb b/lib/critical_path_css/configuration.rb index 737a5f3..5c9dd16 100644 --- a/lib/critical_path_css/configuration.rb +++ b/lib/critical_path_css/configuration.rb @@ -1,39 +1,32 @@ require 'erb' module CriticalPathCss class Configuration - CONFIGURATION_FILENAME = 'critical_path_css.yml' - - def initialize - @configurations = YAML.load(ERB.new(File.read(configuration_file_path)).result)[Rails.env] + def initialize(config) + @config = config end def base_url - @configurations['base_url'] + @config['base_url'] end - def css_path - @css_path ||= begin - relative_path = @configurations['css_path'] || manifest_path - "#{Rails.root}/public#{relative_path}" - end + def css_paths + @config['css_paths'] end def manifest_name - @configurations['manifest_name'] + @config['manifest_name'] end def routes - @configurations['routes'] + @config['routes'] end - private - - def configuration_file_path - @configuration_file_path ||= Rails.root.join('config', CONFIGURATION_FILENAME) + def penthouse_options + @config['penthouse_options'] || {} end - def manifest_path - @manifest_path ||= ActionController::Base.helpers.stylesheet_path(manifest_name, host: '') + def path_for_route(route) + css_paths[routes.index(route).to_i] end end end diff --git a/lib/critical_path_css/css_fetcher.rb b/lib/critical_path_css/css_fetcher.rb index 3397819..987f000 100644 --- a/lib/critical_path_css/css_fetcher.rb +++ b/lib/critical_path_css/css_fetcher.rb @@ -1,22 +1,55 @@ +require 'json' +require 'open3' + module CriticalPathCss class CssFetcher - require 'phantomjs' - require 'critical_path_css/configuration' - - PENTHOUSE_PATH = "#{File.dirname(__FILE__)}/../penthouse/penthouse.js" + GEM_ROOT = File.expand_path(File.join('..', '..'), File.dirname(__FILE__)) - def initialize - @config = Configuration.new + def initialize(config) + @config = config end def fetch - @config.routes.map { |route| [route, css_for_route(route)] }.to_h + @config.routes.map { |route| [route, fetch_route(route)] }.to_h end - private - - def css_for_route(route) - Phantomjs.run(PENTHOUSE_PATH, @config.base_url + route, @config.css_path) + def fetch_route(route) + options = { + 'url' => @config.base_url + route, + 'css' => @config.path_for_route(route), + 'width' => 1300, + 'height' => 900, + 'timeout' => 30_000, + # CSS selectors to always include, e.g.: + 'forceInclude' => [ + # '.keepMeEvenIfNotSeenInDom', + # '^\.regexWorksToo' + ], + # set to true to throw on CSS errors (will run faster if no errors) + 'strict' => false, + # characters; strip out inline base64 encoded resources larger than this + 'maxEmbeddedBase64Length' => 1000, + # specify which user agent string when loading the page + 'userAgent' => 'Penthouse Critical Path CSS Generator', + # ms; render wait timeout before CSS processing starts (default: 100) + 'renderWaitTime' => 100, + # set to false to load (external) JS (default: true) + 'blockJSRequests' => true, + 'customPageHeaders' => { + # use if getting compression errors like 'Data corrupted': + 'Accept-Encoding' => 'identity' + } + }.merge(@config.penthouse_options) + out, err, st = Dir.chdir(GEM_ROOT) do + Open3.capture3('node', 'lib/fetch-css.js', JSON.dump(options)) + end + if !st.exitstatus.zero? || out.empty? && !err.empty? + STDOUT.puts out + STDERR.puts err + STDERR.puts "Failed to get CSS for route #{route}\n" \ + " with options=#{options.inspect}" + end + out end end end diff --git a/lib/critical_path_css/rails/config_loader.rb b/lib/critical_path_css/rails/config_loader.rb new file mode 100644 index 0000000..5addd98 --- /dev/null +++ b/lib/critical_path_css/rails/config_loader.rb @@ -0,0 +1,45 @@ +module CriticalPathCss + module Rails + class ConfigLoader + CONFIGURATION_FILENAME = 'critical_path_css.yml'.freeze + + def initialize + validate_css_paths + format_css_paths + end + + def config + @config ||= YAML.safe_load(ERB.new(File.read(configuration_file_path)).result, [], [], true)[::Rails.env] + end + + private + + def configuration_file_path + @configuration_file_path ||= ::Rails.root.join('config', CONFIGURATION_FILENAME) + end + + def format_css_paths + config['css_paths'] = [config['css_path']] if config['css_path'] + + unless config['css_paths'] + config['css_paths'] = [ActionController::Base.helpers.stylesheet_path(config['manifest_name'], host: '')] + end + config['css_paths'].map! { |path| format_path(path) } + end + + def format_path(path) + "#{::Rails.root}/public#{path}" + end + + def validate_css_paths + if config['manifest_name'] && (config['css_path'] || config['css_paths']) + raise LoadError, 'Cannot specify both manifest_name and css_path(s)' + elsif config['css_path'] && config['css_paths'] + raise LoadError, 'Cannot specify both css_path and css_paths' + elsif config['css_paths'] && config['css_paths'].length != config['routes'].length + raise LoadError, 'Must specify css_paths for each route' + end + end + end + end +end diff --git a/lib/critical_path_css/rails/version.rb b/lib/critical_path_css/rails/version.rb index c7a8caa..f005d4d 100644 --- a/lib/critical_path_css/rails/version.rb +++ b/lib/critical_path_css/rails/version.rb @@ -1,6 +1,5 @@ module CriticalPathCSS module Rails - VERSION = '0.2.3' - PENTHOUSE_VERSION = '0.3.4' + VERSION = '4.1.1'.freeze end end diff --git a/lib/fetch-css.js b/lib/fetch-css.js new file mode 100644 index 0000000..6f1db63 --- /dev/null +++ b/lib/fetch-css.js @@ -0,0 +1,14 @@ +const penthouse = require('penthouse'); +const fs = require('fs'); + +const penthouseOptions = JSON.parse(process.argv[2]); + +const STDOUT_FD = 1; +const STDERR_FD = 2; + +penthouse(penthouseOptions).then(function(criticalCss) { + fs.writeSync(STDOUT_FD, criticalCss); +}).catch(function(err) { + fs.writeSync(STDERR_FD, err); + process.exit(1); +}); diff --git a/lib/generators/critical_path_css/install_generator.rb b/lib/generators/critical_path_css/install_generator.rb index b7c221a..0d26f53 100644 --- a/lib/generators/critical_path_css/install_generator.rb +++ b/lib/generators/critical_path_css/install_generator.rb @@ -1,7 +1,7 @@ require 'rails/generators' module CriticalPathCss - class InstallGenerator < Rails::Generators::Base + class InstallGenerator < ::Rails::Generators::Base source_root File.expand_path('..', __FILE__) # Copy the needed rake task for generating critical CSS diff --git a/lib/npm_commands.rb b/lib/npm_commands.rb new file mode 100644 index 0000000..9d3d0e0 --- /dev/null +++ b/lib/npm_commands.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# NPM wrapper with helpful error messages +class NpmCommands + # @return [Boolean] whether the installation succeeded + def install(*args) + return false unless check_nodejs_installed + STDERR.puts 'Installing npm dependencies...' + install_status = Dir.chdir File.expand_path('..', File.dirname(__FILE__)) do + system('npm', 'install', *args) + end + STDERR.puts( + *if install_status + ['npm dependencies installed'] + else + ['-' * 60, + 'Error: npm dependencies installation failed', + '-' * 60] + end + ) + install_status + end + + private + + def check_nodejs_installed + return true if executable?('node') + STDERR.puts( + '-' * 60, + 'Error: critical-path-css-rails requires NodeJS and NPM.', + *if executable?('brew') + [' To install NodeJS and NPM, run:', + ' brew install node'] + elsif Gem.win_platform? + [' To install NodeJS and NPM, we recommend:', + ' https://github.com/coreybutler/nvm-windows/releases'] + else + [' To install NodeJS and NPM, we recommend:', + ' https://github.com/creationix/nvm'] + end, + '-' * 60 + ) + end + + def executable?(cmd) + exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : [''] + ENV['PATH'].split(File::PATH_SEPARATOR).each do |path| + exts.each do |ext| + exe = File.join(path, "#{cmd}#{ext}") + return exe if File.executable?(exe) && !File.directory?(exe) + end + end + nil + end +end diff --git a/lib/penthouse/penthouse.js b/lib/penthouse/penthouse.js deleted file mode 100644 index ff585dd..0000000 --- a/lib/penthouse/penthouse.js +++ /dev/null @@ -1,601 +0,0 @@ -/* -Penthouse CSS Critical Path Generator -https://github.com/pocketjoso/penthouse -Author: Jonas Ohlsson -License: MIT -Version: 0.3.4 - -USAGE: - phantomjs penthouse.js [options] - Options: - --width The viewport width in pixels. Defaults to 1300 - --height The viewport height in pixels. Defaults to 900 - - to run on HTTPS sites two flags must be passed in, directly after phantomjs in the call: - --ignore-ssl-errors=true --ssl-protocol=tlsv1 - -DEPENDENCIES - + "phantomjs" : "~1.9.7" - -*/ - - -(function() { "use strict"; -/* - * parser for the script - can be used both for the standalone node binary and the phantomjs script - */ - -/*jshint unused:false*/ - -var usageString = '[--width ] [--height ] '; - -function buildError(msg, problemToken, args) { - var error = new Error(msg + problemToken); - error.token = problemToken; - error.args = args; - throw error; -} - -// Parses the arguments passed in -// @returns { width, height, url, css } -// throws an error on wrong options or parsing error -function parseOptions(argsOriginal) { - var args = argsOriginal.slice(0), - validOptions = ['--width', '--height'], - parsed = {}, - val, - len = args.length, - optIndex, - option; - - if (len < 2) buildError('Not enough arguments, ', args); - - while (args.length > 2 && args[0].match(/^(--width|--height)$/)) { - optIndex = validOptions.indexOf(args[0]); - if (optIndex === -1) buildError('Logic/Parsing error ', args[0], args); - - // lose the dashes - option = validOptions[optIndex].slice(2); - val = args[1]; - - parsed[option] = parseInt(val, 10); - if (isNaN(parsed[option])) buildError('Parsing error when parsing ', val, args); - - // remove the two parsed arguments from the list - args = args.slice(2); - } - parsed.url = args[0]; - parsed.css = args[1]; - - if (!parsed.url) { - buildError('Missing url/path to html file', '', args); - } - - if (!parsed.css) { - buildError('Missing css file', '', args); - } - - - return parsed; -} - -if (typeof module !== 'undefined') { - module.exports = exports = { - parse: parseOptions, - usage: usageString - }; -} -/* -module for removing unused fontface rules - can be used both for the standalone node binary and the phantomjs script -*/ -/*jshint unused:false*/ - -function unusedFontfaceRemover (css){ - var toDeleteSections = []; - - //extract full @font-face rules - var fontFaceRegex = /(@font-face[ \s\S]*?\{([\s\S]*?)\})/gm, - ff; - - while ((ff = fontFaceRegex.exec(css)) !== null) { - - //grab the font name declared in the @font-face rule - //(can still be in quotes, f.e. 'Lato Web' - var t = /font-family[^:]*?:[ ]*([^;]*)/.exec(ff[1]); - if (typeof t[1] === 'undefined') - continue; //no font-family in @fontface rule! - - //rm quotes - var fontName = t[1].replace(/['"]/gm, ''); - - // does this fontname appear as a font-family or font (shorthand) value? - var fontNameRegex = new RegExp('([^{}]*?){[^}]*?font(-family)?[^:]*?:[^;]*' + fontName + '[^,;]*[,;]', 'gmi'); - - - var fontFound = false, - m; - - while ((m = fontNameRegex.exec(css)) !== null) { - if (m[1].indexOf('@font-face') === -1) { - //log('FOUND, keep rule'); - fontFound = true; - break; - } - } - if (!fontFound) { - //NOT FOUND, rm! - - //can't remove rule here as it will screw up ongoing while (exec ...) loop. - //instead: save indices and delete AFTER for loop - var closeRuleIndex = css.indexOf('}', ff.index); - //unshift - add to beginning of array - we need to remove rules in reverse order, - //otherwise indeces will become incorrect again. - toDeleteSections.unshift({ - start: ff.index, - end: closeRuleIndex + 1 - }); - } - } - //now delete the @fontface rules we registed as having no matches in the css - for (var i = 0; i < toDeleteSections.length; i++) { - var start = toDeleteSections[i].start, - end = toDeleteSections[i].end; - css = css.substring(0, start) + css.substring(end); - } - - return css; -}; - - - -if(typeof module !== 'undefined') { - module.exports = unusedFontfaceRemover; -} -/*jshint unused:false*/ - -/* === preFormatCSS === - * preformats the css to ensure we won't run into and problems in our parsing - * removes comments (actually would be anough to remove/replace {} chars.. TODO - * replaces } char inside content: '' properties. - */ - -function cssPreformatter (css){ - //remove comments from css (including multi-line coments) - css = css.replace(/\/\*[\s\S]*?\*\//g, ''); - - //replace Windows \r\n with \n, - //otherwise final output might get converted into /r/r/n - css = css.replace(/\r\n/gm, '\n'); - - //we also need to replace eventual close curly bracket characters inside content: '' property declarations, replace them with their ASCI code equivalent - //\7d (same as: '\' + '}'.charCodeAt(0).toString(16) ); - - var m, - regexP = /(content\s*:\s*['"][^'"]*)}([^'"]*['"])/gm, - matchedData = []; - - //for each content: '' rule that contains at least one end bracket ('}') - while ((m = regexP.exec(css)) !== null) { - //we need to replace ALL end brackets in the rule - //we can't do it in here, because it will mess up ongoing exec, store data and do after - - //unshift - add to beginning of array - we need to remove rules in reverse order, - //otherwise indeces will become incorrect. - matchedData.unshift({ - start: m.index, - end: m.index + m[0].length, - replaceStr: m[0].replace(/\}/gm, '\\7d') - }); - } - - for (var i = 0; i < matchedData.length; i++) { - var item = matchedData[0]; - css = css.substring(0, item.start) + item.replaceStr + css.substring(item.end); - } - - return css; -}; - -if(typeof module !== 'undefined') { - module.exports = cssPreformatter; -} -var standaloneMode = true; -'use strict'; -var standaloneMode = standaloneMode || false; - -var page = require('webpage').create(), - fs = require('fs'), - system = require('system'), - DEBUG = false, - stdout = system.stdout; // for using this as a file - -var combineArgsString = function(argsArr) { - return [].join.call(argsArr, ' ') + '\n'; -}; - -// monkey patch for directing errors to stderr -// https://github.com/ariya/phantomjs/issues/10150#issuecomment-28707859 -var errorlog = function() { - system.stderr.write(combineArgsString(arguments)); -}; - -var debug = function() { - if (DEBUG) errorlog('DEBUG: ' + combineArgsString(arguments)); -}; - -// discard stdout from phantom exit; -var phantomExit = function(code) { - if (page) { - page.close(); - } - setTimeout(function() { - phantom.exit(code); - }, 0); -}; - -//don't confuse analytics more than necessary when visiting websites -page.settings.userAgent = 'Penthouse Critical Path CSS Generator'; - -/* prevent page JS errors from being output to final CSS */ -page.onError = function(msg, trace) { - //do nothing -}; - -page.onResourceError = function(resourceError) { - page.reason = resourceError.errorString; - page.reason_url = resourceError.url; -}; - -var main = function(options) { - debug('main(): ', JSON.stringify(options)); -//final cleanup -//remove all empty rules, and remove leading/trailing whitespace - try { - var f = fs.open(options.css, 'r'); - - //preformat css - var cssPreformat; - if (standaloneMode) { - cssPreformat = cssPreformatter; - } else { - cssPreformat = require('./css-preformatter.js'); - } - options.css = cssPreformat(f.read()); - } catch (e) { - errorlog(e); - phantomExit(1); - } - - // start the critical path CSS generation - getCriticalPathCss(options); -}; - -function cleanup(css) { - //remove all animation rules, as keyframes have already been removed - css = css.replace(/(-webkit-|-moz-|-ms-|-o-)?animation[ ]?:[^;{}]*;/gm, ''); - //remove all empty rules, and remove leading/trailing whitespace - return css.replace(/[^{}]*\{\s*\}/gm, '').trim(); -} - -/* Final function - * Get's called from getCriticalPathCss when CSS extraction from page is done*/ -page.onCallback = function(css) { - debug('phantom.onCallback'); - - try { - if (css) { - // we are done - clean up the final css - var finalCss = cleanup(css); - - // remove unused @fontface rules - var ffRemover; - if (standaloneMode) { - ffRemover = unusedFontfaceRemover; - } else { - ffRemover = require('./unused-fontface-remover.js'); - } - finalCss = ffRemover(finalCss); - - if(finalCss.trim().length === 0){ - errorlog('Note: Generated critical css was empty for URL: ' + options.url); - } - - // return the critical css! - stdout.write(finalCss); - phantomExit(0); - } else { - // No css. This is not an error on our part - // but still safer to warn the end user, in case they made a mistake - errorlog('Note: Generated critical css was empty for URL: ' + options.url); - // for consisteny, still generate output (will be empty) - stdout.write(css); - phantomExit(0); - } - - } catch (ex) { - debug('phantom.onCallback -> error', ex); - errorlog('error: ' + ex); - phantomExit(1); - } -}; - -/* - * Tests each selector in css file at specified resolution, - * to see if any such elements appears above the fold on the page - * modifies CSS - removes selectors that don't appear, and empty rules - * - * @param options.url the url as a string - * @param options.css the css as a string - * @param options.width the width of viewport - * @param options.height the height of viewport - ---------------------------------------------------------*/ -function getCriticalPathCss(options) { - debug('getCriticalPathCss():', JSON.stringify(options)); - - page.viewportSize = { - width: options.width, - height: options.height - }; - - page.open(options.url, function(status) { - if (status !== 'success') { - errorlog('Error opening url \'' + page.reason_url + '\': ' + page.reason); - phantomExit(1); - } else { - - debug('Starting sandboxed evaluation of CSS\n', options.css); - // sandboxed environments - no outside references - // arguments and return value must be primitives - // @see http://phantomjs.org/api/webpage/method/evaluate.html - page.evaluate(function sandboxed(css) { - var h = window.innerHeight, - renderWaitTime = 100, //ms TODO: user specifiable through options object - finished = false, - currIndex = 0, - forceRemoveNestedRule = false; - - //split CSS so we can value the (selector) rules separately. - //but first, handle stylesheet initial non nested @-rules. - //they don't come with any associated rules, and should all be kept, - //so just keep them in critical css, but don't include them in split - var splitCSS = css.replace(/@(import|charset|namespace)[^;]*;/g, ''); - var split = splitCSS.split(/[{}]/g); - - var getNewValidCssSelector = function(i) { - var newSel = split[i]; - /* HANDLE Nested @-rules */ - - /*Case 1: @-rule with CSS properties inside [REMAIN] - Can't remove @font-face rules here, don't know if used or not. - Another check at end for this purpose. - */ - if (/@(font-face)/gi.test(newSel)) { - //skip over this rule - currIndex = css.indexOf('}', currIndex) + 1; - return getNewValidCssSelector(i + 2); - } - /*Case 2: @-rule with CSS properties inside [REMOVE] - @page - This case doesn't need any special handling, - as this "selector" won't match anything on the page, - and will therefor be removed, together with it's css props - */ - - /*Case 4: @-rule with full CSS (rules) inside [REMOVE] - @media print|speech|aural, @keyframes - Delete this rule and all its contents - doesn't belong in critical path CSS - */ - else if (/@(media (print|speech|aural)|(([a-z\-])*keyframes))/gi.test(newSel)) { - //force delete on child css rules - forceRemoveNestedRule = true; - return getNewValidCssSelector(i + 1); - } - - /*Case 3: @-rule with full CSS (rules) inside [REMAIN] - This test is executed AFTER Case 4, - since we here match every remaining @media, - after @media print has been removed by Case 4 rule) - - just skip this particular line (i.e. keep), and continue checking the CSS inside as normal - */ - else if (/@(media|(-moz-)?document|supports)/gi.test(newSel)) { - return getNewValidCssSelector(i + 1); - } - /* - Resume normal execution after end of @-media rule with inside CSS rules (Case 3) - Also identify abrupt file end. - */ - else if (newSel.trim().length === 0) { - //abrupt file end - if (i + 1 >= split.length) { - //end of file - finished = true; - return false; - } - //end of @-rule (Case 3) - forceRemoveNestedRule = false; - return getNewValidCssSelector(i + 1); - } - return i; - }; - - var removeSelector = function(sel, selectorsKept) { - var selPos = css.indexOf(sel, currIndex); - - //check what comes next: { or , - var nextComma = css.indexOf(',', selPos); - var nextOpenBracket = css.indexOf('{', selPos); - - if (selectorsKept > 0 || (nextComma > 0 && nextComma < nextOpenBracket)) { - //we already kept selectors from this rule, so rule will stay - - //more selectors in selectorList, cut until (and including) next comma - if (nextComma > 0 && nextComma < nextOpenBracket) { - css = css.substring(0, selPos) + css.substring(nextComma + 1); - } - //final selector, cut until open bracket. Also remove previous comma, as the (new) last selector should not be followed by a comma. - else { - var prevComma = css.lastIndexOf(',', selPos); - css = css.substring(0, prevComma) + css.substring(nextOpenBracket); - } - } else { - //no part of selector (list) matched elements above fold on page - remove whole rule CSS rule - var endRuleBracket = css.indexOf('}', nextOpenBracket); - - css = css.substring(0, selPos) + css.substring(endRuleBracket + 1); - } - }; - - - var processCssRules = function() { - for (var i = 0; i < split.length; i = i + 2) { - //step over non DOM CSS selectors (@-rules) - i = getNewValidCssSelector(i); - - //reach end of CSS - if (finished) { - //call final function to exit outside of phantom evaluate scope - window.callPhantom(css); - } - - var fullSel = split[i]; - //fullSel can contain combined selectors - //,f.e. body, html {} - //split and check one such selector at the time. - var selSplit = fullSel.split(','); - //keep track - if we remove all selectors, we also want to remove the whole rule. - var selectorsKept = 0; - var aboveFold; - - for (var j = 0; j < selSplit.length; j++) { - var sel = selSplit[j]; - - //some selectors can't be matched on page. - //In these cases we test a slightly modified selectors instead, temp. - var temp = sel; - - if (sel.indexOf(':') > -1) { - //handle special case selectors, the ones that contain a semi colon (:) - //many of these selectors can't be matched to anything on page via JS, - //but that still might affect the above the fold styling - - //these psuedo selectors depend on an element, - //so test element instead (would do the same for f.e. :hover, :focus, :active IF we wanted to keep them for critical path css, but we don't) - temp = temp.replace(/(:?:before|:?:after)*/g, ''); - - //if selector is purely psuedo (f.e. ::-moz-placeholder), just keep as is. - //we can't match it to anything on page, but it can impact above the fold styles - if (temp.replace(/:[:]?([a-zA-Z0-9\-\_])*/g, '').trim().length === 0) { - currIndex = css.indexOf(sel, currIndex) + sel.length; - selectorsKept++; - continue; - } - - //handle browser specific psuedo selectors bound to elements, - //Example, button::-moz-focus-inner, input[type=number]::-webkit-inner-spin-button - //remove browser specific pseudo and test for element - temp = temp.replace(/:?:-[a-z-]*/g, ''); - } - - if (!forceRemoveNestedRule) { - //now we have a selector to test, first grab any matching elements - var el; - try { - el = document.querySelectorAll(temp); - } catch (e) { - //not a valid selector, remove it. - removeSelector(sel, 0); - continue; - } - - //check if selector matched element(s) on page.. - aboveFold = false; - - for (var k = 0; k < el.length; k++) { - var testEl = el[k]; - //temporarily force clear none in order to catch elements that clear previous content themselves and who w/o their styles could show up unstyled in above the fold content (if they rely on f.e. 'clear:both;' to clear some main content) - testEl.style.clear = 'none'; - - //check to see if any matched element is above the fold on current page - //(in current viewport size) - if (testEl.getBoundingClientRect().top < h) { - //then we will save this selector - aboveFold = true; - selectorsKept++; - - //update currIndex so we only search from this point from here on. - currIndex = css.indexOf(sel, currIndex); - - //set clear style back to what it was - testEl.style.clear = ''; - //break, because matching 1 element is enough - break; - } - //set clear style back to what it was - testEl.style.clear = ''; - } - } else { - aboveFold = false; - } //force removal of selector - - //if selector didn't match any elements above fold - delete selector from CSS - if (aboveFold === false) { - //update currIndex so we only search from this point from here on. - currIndex = css.indexOf(sel, currIndex); - //remove seletor (also removes rule, if nnothing left) - removeSelector(sel, selectorsKept); - } - } - //if rule stayed, move our cursor forward for matching new selectors - if (selectorsKept > 0) { - currIndex = css.indexOf('}', currIndex) + 1; - } - } - - //we're done - call final function to exit outside of phantom evaluate scope - window.callPhantom(css); - }; - - //give some time (renderWaitTime) for sites like facebook that build their page dynamically, - //otherwise we can miss some selectors (and therefor rules) - //--tradeoff here: if site is too slow with dynamic content, - // it doesn't deserve to be in critical path. - setTimeout(processCssRules, renderWaitTime); - - }, options.css); - } - }); -} - -var parser, parse, usage, options; - -// test to see if we are running as a standalone script -// or as part of the node module -if (standaloneMode) { - parse = parseOptions; - usage = usageString; -} else { - parser = require('../options-parser'); - parse = parser.parse; - usage = parser.usage; -} - -try { - options = parse(system.args.slice(1)); -} catch (ex) { - - errorlog('Caught error parsing arguments: ' + ex.message); - - // the usage string does not make sense to show if running via Node - if(standaloneMode) { - errorlog('\nUsage: phantomjs penthouse.js ' + usage); - } - - phantomExit(1); -} - -// set defaults -if (!options.width) options.width = 1300; -if (!options.height) options.height = 900; - -main(options); -})(); \ No newline at end of file diff --git a/lib/tasks/critical_path_css.rake b/lib/tasks/critical_path_css.rake index 30016fb..7038ceb 100644 --- a/lib/tasks/critical_path_css.rake +++ b/lib/tasks/critical_path_css.rake @@ -3,7 +3,15 @@ require 'critical-path-css-rails' namespace :critical_path_css do desc 'Generate critical CSS for the routes defined' task generate: :environment do - CriticalPathCss.generate + CriticalPathCss.generate_all + end + + desc 'Clear all critical CSS from the cache' + task clear_all: :environment do + # Use the following for Redis cache implmentations + CriticalPathCss.clear_matched('*') + # Some other cache implementations may require the following syntax instead + # CriticalPathCss.clear_matched(/.*/) end end diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1c56b5a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,358 @@ +{ + "name": "critical-path-css-rails", + "version": "2.6.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "agent-base": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", + "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==", + "requires": { + "es6-promisify": "^5.0.0" + } + }, + "async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==" + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, + "css-mediaquery": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", + "integrity": "sha1-aiw3NEkoYYYxxUvTPO3TAdoYvqA=" + }, + "css-tree": { + "version": "1.0.0-alpha.28", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.28.tgz", + "integrity": "sha512-joNNW1gCp3qFFzj4St6zk+Wh/NBv0vM5YbEreZk0SD4S23S+1xBKb6cLDg2uj4P4k/GUMlIm6cKIDqIG+vdt0w==", + "requires": { + "mdn-data": "~1.1.0", + "source-map": "^0.5.3" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, + "es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + }, + "es6-promisify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=", + "requires": { + "es6-promise": "^4.0.3" + } + }, + "extract-zip": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.6.7.tgz", + "integrity": "sha1-qEC0uK9kAyZMjbV/Txp0Mz74H+k=", + "requires": { + "concat-stream": "1.6.2", + "debug": "2.6.9", + "mkdirp": "0.5.1", + "yauzl": "2.4.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "fd-slicer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz", + "integrity": "sha1-i1vL2ewyfFBBv5qwI/1nUPEXfmU=", + "requires": { + "pend": "~1.2.0" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "glob": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", + "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "https-proxy-agent": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz", + "integrity": "sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg==", + "requires": { + "agent-base": "^4.3.0", + "debug": "^3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==" + }, + "mdn-data": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-1.1.4.tgz", + "integrity": "sha512-FSYbp3lyKjyj3E7fMl6rYvUdX0FBXaluGqlFoYESWQlyUTq8R+wp0rkFxoYFqZlHCvsUXGjyJmLQSnXToYhOSA==" + }, + "mime": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.4.tgz", + "integrity": "sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA==" + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + }, + "pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=" + }, + "penthouse": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/penthouse/-/penthouse-2.2.0.tgz", + "integrity": "sha512-Mgw9GpwV2D0ZwaJjW6FaXsybaHe/LiQBb2BcX0LQm+AkzHVMp1gyuy1Z4VQfKf4G2ovGVYg5T60UoO+BArculw==", + "requires": { + "css-mediaquery": "^0.1.2", + "css-tree": "1.0.0-alpha.28", + "debug": "^4.1.1", + "jsesc": "^2.5.2", + "puppeteer": "1.15.0" + } + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==" + }, + "proxy-from-env": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", + "integrity": "sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4=" + }, + "puppeteer": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-1.15.0.tgz", + "integrity": "sha512-D2y5kwA9SsYkNUmcBzu9WZ4V1SGHiQTmgvDZSx6sRYFsgV25IebL4V6FaHjF6MbwLK9C6f3G3pmck9qmwM8H3w==", + "requires": { + "debug": "^4.1.0", + "extract-zip": "^1.6.6", + "https-proxy-agent": "^2.2.1", + "mime": "^2.0.3", + "progress": "^2.0.1", + "proxy-from-env": "^1.0.0", + "rimraf": "^2.6.1", + "ws": "^6.1.0" + } + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "requires": { + "glob": "^7.1.3" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=" + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "ws": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz", + "integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==", + "requires": { + "async-limiter": "~1.0.0" + } + }, + "yauzl": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.4.1.tgz", + "integrity": "sha1-lSj0QtqxsihOWLQ3m7GU4i4MQAU=", + "requires": { + "fd-slicer": "~1.0.1" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c82eb65 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "critical-path-css-rails", + "version": "2.6.0", + "description": "NPM dependencies of critical-path-css-rails", + "private": true, + "directories": { + "lib": "lib" + }, + "dependencies": { + "penthouse": "=2.2.0" + }, + "license": "MIT", + "config": { + "puppeteer_skip_chromium_download": true + } +} diff --git a/spec/features/generate_and_fetch_critical_css_spec.rb b/spec/features/generate_and_fetch_critical_css_spec.rb new file mode 100644 index 0000000..91ce7fb --- /dev/null +++ b/spec/features/generate_and_fetch_critical_css_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper.rb' + +RSpec.describe 'generate and fetch the critical css' do + before do + CriticalPathCss.generate_all + end + + context 'on the root page' do + let(:route) { '/' } + + it 'displays the correct critical CSS' do + visit route + expect(page).to have_content 'p{color:red}' + end + end +end diff --git a/spec/fixtures/files/config/manifest-and-path-both-specified.yml b/spec/fixtures/files/config/manifest-and-path-both-specified.yml new file mode 100644 index 0000000..8575dce --- /dev/null +++ b/spec/fixtures/files/config/manifest-and-path-both-specified.yml @@ -0,0 +1,10 @@ +defaults: &defaults + base_url: http://0.0.0.0:9292 + manifest_name: application + css_path: /test.css + +development: + <<: *defaults + +test: + <<: *defaults \ No newline at end of file diff --git a/spec/fixtures/files/config/manifest-and-paths-both-specified.yml b/spec/fixtures/files/config/manifest-and-paths-both-specified.yml new file mode 100644 index 0000000..990a290 --- /dev/null +++ b/spec/fixtures/files/config/manifest-and-paths-both-specified.yml @@ -0,0 +1,15 @@ +defaults: &defaults + base_url: http://0.0.0.0:9292 + manifest_name: application + css_paths: + - /test.css + - /test2.css + routes: + - / + - /new_route + +development: + <<: *defaults + +test: + <<: *defaults \ No newline at end of file diff --git a/spec/fixtures/files/config/mutliple-css-paths.yml b/spec/fixtures/files/config/mutliple-css-paths.yml new file mode 100644 index 0000000..2c778c2 --- /dev/null +++ b/spec/fixtures/files/config/mutliple-css-paths.yml @@ -0,0 +1,14 @@ +defaults: &defaults + base_url: http://0.0.0.0:9292 + css_paths: + - /test.css + - /test2.css + routes: + - / + - /new_route + +development: + <<: *defaults + +test: + <<: *defaults \ No newline at end of file diff --git a/spec/fixtures/files/config/no-paths-specified.yml b/spec/fixtures/files/config/no-paths-specified.yml new file mode 100644 index 0000000..e327054 --- /dev/null +++ b/spec/fixtures/files/config/no-paths-specified.yml @@ -0,0 +1,11 @@ +defaults: &defaults + base_url: http://0.0.0.0:9292 + manifest_name: application + routes: + - / + +development: + <<: *defaults + +test: + <<: *defaults \ No newline at end of file diff --git a/spec/fixtures/files/config/paths-and-routes-not-same-length.yml b/spec/fixtures/files/config/paths-and-routes-not-same-length.yml new file mode 100644 index 0000000..f53a0ff --- /dev/null +++ b/spec/fixtures/files/config/paths-and-routes-not-same-length.yml @@ -0,0 +1,15 @@ +defaults: &defaults + base_url: http://0.0.0.0:9292 + css_paths: + - /test.css + - /test2.css + routes: + - / + - /new_route + - /newer_route + +development: + <<: *defaults + +test: + <<: *defaults \ No newline at end of file diff --git a/spec/fixtures/files/config/paths-both-specified.yml b/spec/fixtures/files/config/paths-both-specified.yml new file mode 100644 index 0000000..55f6396 --- /dev/null +++ b/spec/fixtures/files/config/paths-both-specified.yml @@ -0,0 +1,15 @@ +defaults: &defaults + base_url: http://0.0.0.0:9292 + css_path: /test.css + css_paths: + - /test.css + - /test2.css + routes: + - / + - /new_route + +development: + <<: *defaults + +test: + <<: *defaults \ No newline at end of file diff --git a/spec/fixtures/files/config/single-css-path.yml b/spec/fixtures/files/config/single-css-path.yml new file mode 100644 index 0000000..90d1711 --- /dev/null +++ b/spec/fixtures/files/config/single-css-path.yml @@ -0,0 +1,11 @@ +defaults: &defaults + base_url: http://0.0.0.0:9292 + css_path: /test.css + routes: + - / + +development: + <<: *defaults + +test: + <<: *defaults \ No newline at end of file diff --git a/spec/internal/app/controllers/root_controller.rb b/spec/internal/app/controllers/root_controller.rb new file mode 100644 index 0000000..eb82de8 --- /dev/null +++ b/spec/internal/app/controllers/root_controller.rb @@ -0,0 +1,3 @@ +class RootController < ActionController::Base + def index; end +end diff --git a/spec/internal/app/views/layouts/application.html.erb b/spec/internal/app/views/layouts/application.html.erb new file mode 100644 index 0000000..a98b7c4 --- /dev/null +++ b/spec/internal/app/views/layouts/application.html.erb @@ -0,0 +1,4 @@ + + + <%= yield %> + diff --git a/spec/internal/app/views/root/index.html.erb b/spec/internal/app/views/root/index.html.erb new file mode 100644 index 0000000..6103952 --- /dev/null +++ b/spec/internal/app/views/root/index.html.erb @@ -0,0 +1,2 @@ +

Critical Path CSS Rails Test App

+

<%= CriticalPathCss.fetch(request.path) %>

diff --git a/spec/internal/config/critical_path_css.yml b/spec/internal/config/critical_path_css.yml new file mode 100644 index 0000000..e40cc85 --- /dev/null +++ b/spec/internal/config/critical_path_css.yml @@ -0,0 +1,11 @@ +defaults: &defaults + base_url: http://0.0.0.0:9292 + css_path: /test.css + routes: + - / + +development: + <<: *defaults + +test: + <<: *defaults diff --git a/spec/internal/config/database.yml b/spec/internal/config/database.yml new file mode 100644 index 0000000..b978119 --- /dev/null +++ b/spec/internal/config/database.yml @@ -0,0 +1,3 @@ +test: + adapter: sqlite3 + database: db/combustion_test.sqlite diff --git a/spec/internal/config/routes.rb b/spec/internal/config/routes.rb new file mode 100644 index 0000000..d09aa7b --- /dev/null +++ b/spec/internal/config/routes.rb @@ -0,0 +1,3 @@ +Rails.application.routes.draw do + root 'root#index' +end diff --git a/spec/internal/db/schema.rb b/spec/internal/db/schema.rb new file mode 100644 index 0000000..a4ab1bb --- /dev/null +++ b/spec/internal/db/schema.rb @@ -0,0 +1,3 @@ +ActiveRecord::Schema.define do + # +end diff --git a/spec/internal/log/.gitignore b/spec/internal/log/.gitignore new file mode 100644 index 0000000..bf0824e --- /dev/null +++ b/spec/internal/log/.gitignore @@ -0,0 +1 @@ +*.log \ No newline at end of file diff --git a/spec/internal/public/favicon.ico b/spec/internal/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/spec/internal/public/test.css b/spec/internal/public/test.css new file mode 100644 index 0000000..e21e2c2 --- /dev/null +++ b/spec/internal/public/test.css @@ -0,0 +1,3 @@ +p { + color: red; +} \ No newline at end of file diff --git a/spec/lib/critical_path_css/css_fetcher_spec.rb b/spec/lib/critical_path_css/css_fetcher_spec.rb new file mode 100644 index 0000000..e795af4 --- /dev/null +++ b/spec/lib/critical_path_css/css_fetcher_spec.rb @@ -0,0 +1,84 @@ +require 'spec_helper' + +RSpec.describe 'CssFetcher' do + subject { CriticalPathCss::CssFetcher.new(config) } + + let(:base_url) { 'http://0.0.0.0:9292' } + let(:response) { ['foo','', OpenStruct.new(exitstatus: 0)] } + let(:routes) { ['/', '/new_route'] } + let(:config) do + CriticalPathCss::Configuration.new( + OpenStruct.new( + base_url: base_url, + css_paths: css_paths, + penthouse_options: {}, + routes: routes + ) + ) + end + + describe '#fetch_route' do + context 'when a single css_path is configured' do + let(:css_paths) { ['/test.css'] } + + it 'generates css for the single route' do + expect(Open3).to receive(:capture3) do |arg1, arg2, arg3| + options = JSON.parse(arg3) + + expect(options['css']).to eq '/test.css' + end.once.and_return(response) + + subject.fetch_route(routes.first) + end + end + end + + describe '#fetch' do + context 'when a single css_path is configured' do + let(:css_paths) { ['/test.css'] } + + it 'generates css for each route from the same file' do + expect(Open3).to receive(:capture3) do |arg1, arg2, arg3| + options = JSON.parse(arg3) + + expect(options['css']).to eq '/test.css' + end.twice.and_return(response) + + subject.fetch + end + end + + context 'when multiple css_paths are configured' do + let(:css_paths) { ['/test.css', '/test2.css'] } + + it 'generates css for each route from the respective file' do + expect(Open3).to receive(:capture3) do |arg1, arg2, arg3| + options = JSON.parse(arg3) + + css_paths.each_with_index do |path, index| + expect(options['css']).to eq path if options['url'] == "#{base_url}/#{routes[index]}" + end + end.twice.and_return(response) + + subject.fetch + end + end + + context 'when same css file applies to multiple routes' do + let(:css_paths) { ['/test.css', '/test2.css', '/test.css'] } + let(:routes) { ['/', '/new_route', '/newer_route'] } + + it 'generates css for each route from the respective file' do + expect(Open3).to receive(:capture3) do |arg1, arg2, arg3| + options = JSON.parse(arg3) + + css_paths.each_with_index do |path, index| + expect(options['css']).to eq path if options['url'] == "#{base_url}/#{routes[index]}" + end + end.thrice.and_return(response) + + subject.fetch + end + end + end +end diff --git a/spec/lib/critical_path_css/rails/config_loader_spec.rb b/spec/lib/critical_path_css/rails/config_loader_spec.rb new file mode 100644 index 0000000..dc7bfac --- /dev/null +++ b/spec/lib/critical_path_css/rails/config_loader_spec.rb @@ -0,0 +1,67 @@ +require 'spec_helper' + +RSpec.describe 'ConfigLoader' do + subject { CriticalPathCss::Rails::ConfigLoader.new } + + describe '#load' do + before do + allow(File).to receive(:read).and_return(config_file) + end + + context 'when single css_path is specified' do + let(:config_file) { file_fixture('config/single-css-path.yml').read } + + it 'sets css_paths with the lone path' do + expect(subject.config['css_paths']).to eq ['/app/spec/internal/public/test.css'] + end + end + + context 'when multiple css_paths are specified' do + let(:config_file) { file_fixture('config/mutliple-css-paths.yml').read } + + it 'leaves css_paths to an array of paths' do + expect(subject.config['css_paths']).to eq ['/app/spec/internal/public/test.css','/app/spec/internal/public/test2.css'] + end + end + + context 'when no paths are specified' do + let(:config_file) { file_fixture('config/no-paths-specified.yml').read } + + it 'sets css_paths with the lone manifest path' do + expect(subject.config['css_paths']).to eq ['/stylesheets/application.css'] + end + end + + context 'when manifest name and css path are both specified' do + let(:config_file) { file_fixture('config/manifest-and-path-both-specified.yml').read } + + it 'raises an error' do + expect { subject }.to raise_error LoadError, 'Cannot specify both manifest_name and css_path(s)' + end + end + + context 'when manifest name and css paths are both specified' do + let(:config_file) { file_fixture('config/manifest-and-paths-both-specified.yml').read } + + it 'raises an error' do + expect { subject }.to raise_error LoadError, 'Cannot specify both manifest_name and css_path(s)' + end + end + + context 'when single css_path and multiple css_paths are both specified' do + let(:config_file) { file_fixture('config/paths-both-specified.yml').read } + + it 'raises an error' do + expect { subject }.to raise_error LoadError, 'Cannot specify both css_path and css_paths' + end + end + + context 'when css_paths and routes are not the same length' do + let(:config_file) { file_fixture('config/paths-and-routes-not-same-length.yml').read } + + it 'raises an error' do + expect { subject }.to raise_error LoadError, 'Must specify css_paths for each route' + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..8743788 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'bundler' + +Bundler.require :default, :development + +Combustion.initialize! :action_controller, :action_view + +require 'rspec/rails' +require 'capybara/rails' + +RSpec.configure do |config| + config.include Capybara::DSL + + config.use_transactional_fixtures = true + + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = '.rspec_status' + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end