├── spec ├── internal │ ├── log │ │ └── .gitignore │ ├── public │ │ ├── favicon.ico │ │ └── test.css │ ├── db │ │ └── schema.rb │ ├── config │ │ ├── routes.rb │ │ ├── database.yml │ │ └── critical_path_css.yml │ └── app │ │ ├── controllers │ │ └── root_controller.rb │ │ └── views │ │ ├── layouts │ │ └── application.html.erb │ │ └── root │ │ └── index.html.erb ├── fixtures │ └── files │ │ └── config │ │ ├── single-css-path.yml │ │ ├── no-paths-specified.yml │ │ ├── manifest-and-path-both-specified.yml │ │ ├── mutliple-css-paths.yml │ │ ├── paths-both-specified.yml │ │ ├── paths-and-routes-not-same-length.yml │ │ └── manifest-and-paths-both-specified.yml ├── features │ └── generate_and_fetch_critical_css_spec.rb ├── spec_helper.rb └── lib │ └── critical_path_css │ ├── rails │ └── config_loader_spec.rb │ └── css_fetcher_spec.rb ├── app_container_name ├── docker └── ruby │ ├── .env │ ├── startup.dev │ └── Dockerfile ├── .codeclimate.yml ├── ext └── npm │ ├── extconf.rb │ └── install.rb ├── lib ├── critical_path_css │ ├── rails │ │ ├── version.rb │ │ ├── engine.rb │ │ └── config_loader.rb │ ├── configuration.rb │ └── css_fetcher.rb ├── fetch-css.js ├── config │ └── critical_path_css.yml ├── tasks │ └── critical_path_css.rake ├── generators │ └── critical_path_css │ │ └── install_generator.rb ├── critical-path-css-rails.rb └── npm_commands.rb ├── config.ru ├── .rubocop.yml ├── .gitignore ├── package.json ├── BACKLOG.md ├── docker-compose.yml ├── Gemfile ├── critical-path-css-rails.gemspec ├── LICENSE ├── .rubocop_todo.yml └── README.md /spec/internal/log/.gitignore: -------------------------------------------------------------------------------- 1 | *.log -------------------------------------------------------------------------------- /spec/internal/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app_container_name: -------------------------------------------------------------------------------- 1 | criticalpathcss_ruby -------------------------------------------------------------------------------- /docker/ruby/.env: -------------------------------------------------------------------------------- 1 | RAILS_ENV=development 2 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | languages: 2 | Ruby: true 3 | -------------------------------------------------------------------------------- /spec/internal/public/test.css: -------------------------------------------------------------------------------- 1 | p { 2 | color: red; 3 | } -------------------------------------------------------------------------------- /spec/internal/db/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | # 3 | end 4 | -------------------------------------------------------------------------------- /spec/internal/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | root 'root#index' 3 | end 4 | -------------------------------------------------------------------------------- /spec/internal/config/database.yml: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: sqlite3 3 | database: db/combustion_test.sqlite 4 | -------------------------------------------------------------------------------- /ext/npm/extconf.rb: -------------------------------------------------------------------------------- 1 | File.write 'Makefile', 2 | "make:\n\t\ninstall:\n\truby install.rb\nclean:\n\t\n" 3 | -------------------------------------------------------------------------------- /spec/internal/app/controllers/root_controller.rb: -------------------------------------------------------------------------------- 1 | class RootController < ActionController::Base 2 | def index; end 3 | end 4 | -------------------------------------------------------------------------------- /spec/internal/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 |
3 | <%= yield %> 4 | 5 | -------------------------------------------------------------------------------- /lib/critical_path_css/rails/version.rb: -------------------------------------------------------------------------------- 1 | module CriticalPathCSS 2 | module Rails 3 | VERSION = '4.1.1'.freeze 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/internal/app/views/root/index.html.erb: -------------------------------------------------------------------------------- 1 |<%= CriticalPathCss.fetch(request.path) %>
3 | -------------------------------------------------------------------------------- /lib/critical_path_css/rails/engine.rb: -------------------------------------------------------------------------------- 1 | module CriticalPathCss 2 | module Rails 3 | class Engine < ::Rails::Engine 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /docker/ruby/startup.dev: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | bundle check || bundle install 4 | bundle update critical-path-css-rails 5 | 6 | bundle exec rackup --host 0.0.0.0 7 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler' 3 | require 'combustion' 4 | 5 | Combustion.initialize! :action_controller, :action_view 6 | run Combustion::Application 7 | -------------------------------------------------------------------------------- /ext/npm/install.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../lib/npm_commands' 2 | 3 | NpmCommands.new.install('--production', '.') || 4 | raise('Error while installing npm dependencies') 5 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | AllCops: 4 | Exclude: 5 | - '*.gemspec' 6 | - 'spec/*_helper.rb' 7 | - 'Gemfile' 8 | - 'Rakefile' 9 | 10 | Documentation: 11 | Enabled: false -------------------------------------------------------------------------------- /spec/fixtures/files/config/single-css-path.yml: -------------------------------------------------------------------------------- 1 | defaults: &defaults 2 | base_url: http://0.0.0.0:9292 3 | css_path: /test.css 4 | routes: 5 | - / 6 | 7 | development: 8 | <<: *defaults 9 | 10 | test: 11 | <<: *defaults -------------------------------------------------------------------------------- /spec/internal/config/critical_path_css.yml: -------------------------------------------------------------------------------- 1 | defaults: &defaults 2 | base_url: http://0.0.0.0:9292 3 | css_path: /test.css 4 | routes: 5 | - / 6 | 7 | development: 8 | <<: *defaults 9 | 10 | test: 11 | <<: *defaults 12 | -------------------------------------------------------------------------------- /spec/fixtures/files/config/no-paths-specified.yml: -------------------------------------------------------------------------------- 1 | defaults: &defaults 2 | base_url: http://0.0.0.0:9292 3 | manifest_name: application 4 | routes: 5 | - / 6 | 7 | development: 8 | <<: *defaults 9 | 10 | test: 11 | <<: *defaults -------------------------------------------------------------------------------- /spec/fixtures/files/config/manifest-and-path-both-specified.yml: -------------------------------------------------------------------------------- 1 | defaults: &defaults 2 | base_url: http://0.0.0.0:9292 3 | manifest_name: application 4 | css_path: /test.css 5 | 6 | development: 7 | <<: *defaults 8 | 9 | test: 10 | <<: *defaults -------------------------------------------------------------------------------- /spec/fixtures/files/config/mutliple-css-paths.yml: -------------------------------------------------------------------------------- 1 | defaults: &defaults 2 | base_url: http://0.0.0.0:9292 3 | css_paths: 4 | - /test.css 5 | - /test2.css 6 | routes: 7 | - / 8 | - /new_route 9 | 10 | development: 11 | <<: *defaults 12 | 13 | test: 14 | <<: *defaults -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | 19 | .DS_Store 20 | /node_modules/ 21 | .rspec_status 22 | /npm-debug.log 23 | -------------------------------------------------------------------------------- /spec/fixtures/files/config/paths-both-specified.yml: -------------------------------------------------------------------------------- 1 | defaults: &defaults 2 | base_url: http://0.0.0.0:9292 3 | css_path: /test.css 4 | css_paths: 5 | - /test.css 6 | - /test2.css 7 | routes: 8 | - / 9 | - /new_route 10 | 11 | development: 12 | <<: *defaults 13 | 14 | test: 15 | <<: *defaults -------------------------------------------------------------------------------- /spec/fixtures/files/config/paths-and-routes-not-same-length.yml: -------------------------------------------------------------------------------- 1 | defaults: &defaults 2 | base_url: http://0.0.0.0:9292 3 | css_paths: 4 | - /test.css 5 | - /test2.css 6 | routes: 7 | - / 8 | - /new_route 9 | - /newer_route 10 | 11 | development: 12 | <<: *defaults 13 | 14 | test: 15 | <<: *defaults -------------------------------------------------------------------------------- /spec/fixtures/files/config/manifest-and-paths-both-specified.yml: -------------------------------------------------------------------------------- 1 | defaults: &defaults 2 | base_url: http://0.0.0.0:9292 3 | manifest_name: application 4 | css_paths: 5 | - /test.css 6 | - /test2.css 7 | routes: 8 | - / 9 | - /new_route 10 | 11 | development: 12 | <<: *defaults 13 | 14 | test: 15 | <<: *defaults -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "critical-path-css-rails", 3 | "version": "2.6.0", 4 | "description": "NPM dependencies of critical-path-css-rails", 5 | "private": true, 6 | "directories": { 7 | "lib": "lib" 8 | }, 9 | "dependencies": { 10 | "penthouse": "=2.2.0" 11 | }, 12 | "license": "MIT", 13 | "config": { 14 | "puppeteer_skip_chromium_download": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /lib/fetch-css.js: -------------------------------------------------------------------------------- 1 | const penthouse = require('penthouse'); 2 | const fs = require('fs'); 3 | 4 | const penthouseOptions = JSON.parse(process.argv[2]); 5 | 6 | const STDOUT_FD = 1; 7 | const STDERR_FD = 2; 8 | 9 | penthouse(penthouseOptions).then(function(criticalCss) { 10 | fs.writeSync(STDOUT_FD, criticalCss); 11 | }).catch(function(err) { 12 | fs.writeSync(STDERR_FD, err); 13 | process.exit(1); 14 | }); 15 | -------------------------------------------------------------------------------- /spec/features/generate_and_fetch_critical_css_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | RSpec.describe 'generate and fetch the critical css' do 4 | before do 5 | CriticalPathCss.generate_all 6 | end 7 | 8 | context 'on the root page' do 9 | let(:route) { '/' } 10 | 11 | it 'displays the correct critical CSS' do 12 | visit route 13 | expect(page).to have_content 'p{color:red}' 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /BACKLOG.md: -------------------------------------------------------------------------------- 1 | # Backlog 2 | 3 | ## Features 4 | - Allow the user to give a single route for a Controller#Show route, instead of hard coding every unique Resource#Show URL 5 | * Implementation should account for any route that allows variables/parameters in the URL 6 | - Error reporting during CSS generation (404, 500 errors, etc.) 7 | - Improve installation process, if possible 8 | - Improve implementation. Is their a better solution then using Rails.cache? -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | ruby: 4 | build: 5 | context: . 6 | dockerfile: docker/ruby/Dockerfile 7 | ports: 8 | - 9292:9292 9 | volumes: 10 | - .:/app:rw 11 | volumes_from: 12 | - data 13 | env_file: docker/ruby/.env 14 | container_name: criticalpathcss_ruby 15 | data: 16 | build: 17 | context: . 18 | dockerfile: docker/ruby/Dockerfile 19 | volumes: 20 | - /gems 21 | command: "true" -------------------------------------------------------------------------------- /lib/config/critical_path_css.yml: -------------------------------------------------------------------------------- 1 | defaults: &defaults 2 | # If using the asset pipeline, provide the manifest name 3 | manifest_name: application 4 | # Else provide the relative path of your CSS file from the /public directory 5 | # css_path: /path/to/css/from/public/main.css 6 | # Or provide a separate path for each route 7 | # css_paths: 8 | # - /path/to/css/from/public/main.css 9 | routes: 10 | - / 11 | 12 | development: 13 | <<: *defaults 14 | base_url: http://localhost:3000 15 | 16 | staging: 17 | <<: *defaults 18 | base_url: http://staging.example.com 19 | 20 | production: 21 | <<: *defaults 22 | base_url: http://example.com 23 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | group :development, :test do 6 | gem 'actionpack' 7 | gem 'byebug', platform: [:ruby], require: false 8 | gem 'rubocop', require: false 9 | gem 'rspec-rails', '~> 3.8' 10 | gem 'capybara', '~> 3.14' 11 | gem 'pry-rails' 12 | end 13 | 14 | # HACK: npm install on bundle 15 | unless $npm_commands_hook_installed # rubocop:disable Style/GlobalVars 16 | Gem.pre_install do |installer| 17 | next true unless installer.spec.name == 'critical-path-css-rails' 18 | require_relative './ext/npm/install' 19 | end 20 | $npm_commands_hook_installed = true # rubocop:disable Style/GlobalVars 21 | end 22 | -------------------------------------------------------------------------------- /lib/critical_path_css/configuration.rb: -------------------------------------------------------------------------------- 1 | require 'erb' 2 | module CriticalPathCss 3 | class Configuration 4 | def initialize(config) 5 | @config = config 6 | end 7 | 8 | def base_url 9 | @config['base_url'] 10 | end 11 | 12 | def css_paths 13 | @config['css_paths'] 14 | end 15 | 16 | def manifest_name 17 | @config['manifest_name'] 18 | end 19 | 20 | def routes 21 | @config['routes'] 22 | end 23 | 24 | def penthouse_options 25 | @config['penthouse_options'] || {} 26 | end 27 | 28 | def path_for_route(route) 29 | css_paths[routes.index(route).to_i] 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/tasks/critical_path_css.rake: -------------------------------------------------------------------------------- 1 | require 'critical-path-css-rails' 2 | 3 | namespace :critical_path_css do 4 | desc 'Generate critical CSS for the routes defined' 5 | task generate: :environment do 6 | CriticalPathCss.generate_all 7 | end 8 | 9 | desc 'Clear all critical CSS from the cache' 10 | task clear_all: :environment do 11 | # Use the following for Redis cache implmentations 12 | CriticalPathCss.clear_matched('*') 13 | # Some other cache implementations may require the following syntax instead 14 | # CriticalPathCss.clear_matched(/.*/) 15 | end 16 | end 17 | 18 | Rake::Task['assets:precompile'].enhance { Rake::Task['critical_path_css:generate'].invoke } 19 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler' 4 | 5 | Bundler.require :default, :development 6 | 7 | Combustion.initialize! :action_controller, :action_view 8 | 9 | require 'rspec/rails' 10 | require 'capybara/rails' 11 | 12 | RSpec.configure do |config| 13 | config.include Capybara::DSL 14 | 15 | config.use_transactional_fixtures = true 16 | 17 | # Enable flags like --only-failures and --next-failure 18 | config.example_status_persistence_file_path = '.rspec_status' 19 | 20 | # Disable RSpec exposing methods globally on `Module` and `main` 21 | config.disable_monkey_patching! 22 | 23 | config.expect_with :rspec do |c| 24 | c.syntax = :expect 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/generators/critical_path_css/install_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | 3 | module CriticalPathCss 4 | class InstallGenerator < ::Rails::Generators::Base 5 | source_root File.expand_path('..', __FILE__) 6 | 7 | # Copy the needed rake task for generating critical CSS 8 | def copy_rake_task 9 | task_filename = 'critical_path_css.rake' 10 | copy_file "../../tasks/#{task_filename}", "lib/tasks/#{task_filename}" 11 | end 12 | 13 | # Copy the needed configuration YAML file for generating critical CSS 14 | def copy_config_file 15 | task_filename = 'critical_path_css.yml' 16 | copy_file "../../config/#{task_filename}", "config/#{task_filename}" 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /docker/ruby/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:2.5.0 2 | 3 | # Install Dependencies 4 | RUN curl -sL https://deb.nodesource.com/setup_8.x | bash - 5 | RUN apt-get update && apt-get install -y build-essential libpq-dev nodejs npm 6 | 7 | # Install Penthouse JS Dependencies 8 | 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 9 | 10 | # Configure Node/NPM 11 | RUN npm cache clean -f 12 | RUN npm install -g n 13 | RUN n 10.15.1 14 | RUN ln -sf /usr/local/n/versions/node/10.15.1/bin/node /usr/bin/nodejs 15 | 16 | ENV BUNDLE_PATH /gems 17 | 18 | WORKDIR /app 19 | 20 | COPY docker/ruby/startup.dev /usr/local/bin/startup 21 | RUN chmod 755 /usr/local/bin/startup 22 | CMD "/usr/local/bin/startup" -------------------------------------------------------------------------------- /critical-path-css-rails.gemspec: -------------------------------------------------------------------------------- 1 | require File.expand_path('../lib/critical_path_css/rails/version', __FILE__) 2 | 3 | Gem::Specification.new do |gem| 4 | gem.name = 'critical-path-css-rails' 5 | gem.version = CriticalPathCSS::Rails::VERSION 6 | gem.platform = Gem::Platform::RUBY 7 | gem.authors = ['Michael Misshore'] 8 | gem.email = 'mmisshore@gmail.com' 9 | gem.summary = 'Critical Path CSS for Rails!' 10 | gem.description = 'Only load the CSS you need for the initial viewport in Rails!' 11 | gem.license = 'MIT' 12 | gem.homepage = 'https://rubygems.org/gems/critical-path-css-rails' 13 | 14 | gem.files = `git ls-files`.split("\n") 15 | gem.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } 16 | gem.require_path = 'lib' 17 | 18 | gem.add_development_dependency 'combustion', '~> 1.1', '>= 1.1.0' 19 | 20 | gem.extensions = ['ext/npm/extconf.rb'] 21 | end 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) [2015] [Mudbug Media, Michael Misshore] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/critical-path-css-rails.rb: -------------------------------------------------------------------------------- 1 | require 'critical_path_css/configuration' 2 | require 'critical_path_css/css_fetcher' 3 | require 'critical_path_css/rails/config_loader' 4 | 5 | module CriticalPathCss 6 | CACHE_NAMESPACE = 'critical-path-css'.freeze 7 | 8 | def self.generate(route) 9 | ::Rails.cache.write(route, fetcher.fetch_route(route), namespace: CACHE_NAMESPACE, expires_in: nil) 10 | end 11 | 12 | def self.generate_all 13 | fetcher.fetch.each do |route, css| 14 | ::Rails.cache.write(route, css, namespace: CACHE_NAMESPACE, expires_in: nil) 15 | end 16 | end 17 | 18 | def self.clear(route) 19 | ::Rails.cache.delete(route, namespace: CACHE_NAMESPACE) 20 | end 21 | 22 | def self.clear_matched(routes) 23 | ::Rails.cache.delete_matched(routes, namespace: CACHE_NAMESPACE) 24 | end 25 | 26 | def self.fetch(route) 27 | ::Rails.cache.read(route, namespace: CACHE_NAMESPACE) || '' 28 | end 29 | 30 | def self.fetcher 31 | @fetcher ||= CssFetcher.new(Configuration.new(config_loader.config)) 32 | end 33 | 34 | def self.config_loader 35 | @config_loader ||= CriticalPathCss::Rails::ConfigLoader.new 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2017-12-29 15:04:25 +0000 using RuboCop version 0.52.1. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 1 10 | Metrics/AbcSize: 11 | Max: 19 12 | 13 | # Offense count: 1 14 | # Configuration parameters: CountComments. 15 | Metrics/MethodLength: 16 | Max: 31 17 | 18 | # Offense count: 1 19 | Naming/AccessorMethodName: 20 | Exclude: 21 | - 'spec/support/static_file_server.rb' 22 | 23 | # Offense count: 1 24 | # Configuration parameters: ExpectMatchingDefinition, Regex, IgnoreExecutableScripts, AllowedAcronyms. 25 | # 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 26 | Naming/FileName: 27 | Exclude: 28 | - 'lib/critical-path-css-rails.rb' 29 | 30 | # Offense count: 4 31 | # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. 32 | # URISchemes: http, https 33 | Metrics/LineLength: 34 | Max: 96 35 | -------------------------------------------------------------------------------- /lib/critical_path_css/rails/config_loader.rb: -------------------------------------------------------------------------------- 1 | module CriticalPathCss 2 | module Rails 3 | class ConfigLoader 4 | CONFIGURATION_FILENAME = 'critical_path_css.yml'.freeze 5 | 6 | def initialize 7 | validate_css_paths 8 | format_css_paths 9 | end 10 | 11 | def config 12 | @config ||= YAML.safe_load(ERB.new(File.read(configuration_file_path)).result, [], [], true)[::Rails.env] 13 | end 14 | 15 | private 16 | 17 | def configuration_file_path 18 | @configuration_file_path ||= ::Rails.root.join('config', CONFIGURATION_FILENAME) 19 | end 20 | 21 | def format_css_paths 22 | config['css_paths'] = [config['css_path']] if config['css_path'] 23 | 24 | unless config['css_paths'] 25 | config['css_paths'] = [ActionController::Base.helpers.stylesheet_path(config['manifest_name'], host: '')] 26 | end 27 | config['css_paths'].map! { |path| format_path(path) } 28 | end 29 | 30 | def format_path(path) 31 | "#{::Rails.root}/public#{path}" 32 | end 33 | 34 | def validate_css_paths 35 | if config['manifest_name'] && (config['css_path'] || config['css_paths']) 36 | raise LoadError, 'Cannot specify both manifest_name and css_path(s)' 37 | elsif config['css_path'] && config['css_paths'] 38 | raise LoadError, 'Cannot specify both css_path and css_paths' 39 | elsif config['css_paths'] && config['css_paths'].length != config['routes'].length 40 | raise LoadError, 'Must specify css_paths for each route' 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/npm_commands.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # NPM wrapper with helpful error messages 4 | class NpmCommands 5 | # @return [Boolean] whether the installation succeeded 6 | def install(*args) 7 | return false unless check_nodejs_installed 8 | STDERR.puts 'Installing npm dependencies...' 9 | install_status = Dir.chdir File.expand_path('..', File.dirname(__FILE__)) do 10 | system('npm', 'install', *args) 11 | end 12 | STDERR.puts( 13 | *if install_status 14 | ['npm dependencies installed'] 15 | else 16 | ['-' * 60, 17 | 'Error: npm dependencies installation failed', 18 | '-' * 60] 19 | end 20 | ) 21 | install_status 22 | end 23 | 24 | private 25 | 26 | def check_nodejs_installed 27 | return true if executable?('node') 28 | STDERR.puts( 29 | '-' * 60, 30 | 'Error: critical-path-css-rails requires NodeJS and NPM.', 31 | *if executable?('brew') 32 | [' To install NodeJS and NPM, run:', 33 | ' brew install node'] 34 | elsif Gem.win_platform? 35 | [' To install NodeJS and NPM, we recommend:', 36 | ' https://github.com/coreybutler/nvm-windows/releases'] 37 | else 38 | [' To install NodeJS and NPM, we recommend:', 39 | ' https://github.com/creationix/nvm'] 40 | end, 41 | '-' * 60 42 | ) 43 | end 44 | 45 | def executable?(cmd) 46 | exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : [''] 47 | ENV['PATH'].split(File::PATH_SEPARATOR).each do |path| 48 | exts.each do |ext| 49 | exe = File.join(path, "#{cmd}#{ext}") 50 | return exe if File.executable?(exe) && !File.directory?(exe) 51 | end 52 | end 53 | nil 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/critical_path_css/css_fetcher.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'open3' 3 | 4 | module CriticalPathCss 5 | class CssFetcher 6 | GEM_ROOT = File.expand_path(File.join('..', '..'), File.dirname(__FILE__)) 7 | 8 | def initialize(config) 9 | @config = config 10 | end 11 | 12 | def fetch 13 | @config.routes.map { |route| [route, fetch_route(route)] }.to_h 14 | end 15 | 16 | def fetch_route(route) 17 | options = { 18 | 'url' => @config.base_url + route, 19 | 'css' => @config.path_for_route(route), 20 | 'width' => 1300, 21 | 'height' => 900, 22 | 'timeout' => 30_000, 23 | # CSS selectors to always include, e.g.: 24 | 'forceInclude' => [ 25 | # '.keepMeEvenIfNotSeenInDom', 26 | # '^\.regexWorksToo' 27 | ], 28 | # set to true to throw on CSS errors (will run faster if no errors) 29 | 'strict' => false, 30 | # characters; strip out inline base64 encoded resources larger than this 31 | 'maxEmbeddedBase64Length' => 1000, 32 | # specify which user agent string when loading the page 33 | 'userAgent' => 'Penthouse Critical Path CSS Generator', 34 | # ms; render wait timeout before CSS processing starts (default: 100) 35 | 'renderWaitTime' => 100, 36 | # set to false to load (external) JS (default: true) 37 | 'blockJSRequests' => true, 38 | 'customPageHeaders' => { 39 | # use if getting compression errors like 'Data corrupted': 40 | 'Accept-Encoding' => 'identity' 41 | } 42 | }.merge(@config.penthouse_options) 43 | out, err, st = Dir.chdir(GEM_ROOT) do 44 | Open3.capture3('node', 'lib/fetch-css.js', JSON.dump(options)) 45 | end 46 | if !st.exitstatus.zero? || out.empty? && !err.empty? 47 | STDOUT.puts out 48 | STDERR.puts err 49 | STDERR.puts "Failed to get CSS for route #{route}\n" \ 50 | " with options=#{options.inspect}" 51 | end 52 | out 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/lib/critical_path_css/rails/config_loader_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe 'ConfigLoader' do 4 | subject { CriticalPathCss::Rails::ConfigLoader.new } 5 | 6 | describe '#load' do 7 | before do 8 | allow(File).to receive(:read).and_return(config_file) 9 | end 10 | 11 | context 'when single css_path is specified' do 12 | let(:config_file) { file_fixture('config/single-css-path.yml').read } 13 | 14 | it 'sets css_paths with the lone path' do 15 | expect(subject.config['css_paths']).to eq ['/app/spec/internal/public/test.css'] 16 | end 17 | end 18 | 19 | context 'when multiple css_paths are specified' do 20 | let(:config_file) { file_fixture('config/mutliple-css-paths.yml').read } 21 | 22 | it 'leaves css_paths to an array of paths' do 23 | expect(subject.config['css_paths']).to eq ['/app/spec/internal/public/test.css','/app/spec/internal/public/test2.css'] 24 | end 25 | end 26 | 27 | context 'when no paths are specified' do 28 | let(:config_file) { file_fixture('config/no-paths-specified.yml').read } 29 | 30 | it 'sets css_paths with the lone manifest path' do 31 | expect(subject.config['css_paths']).to eq ['/stylesheets/application.css'] 32 | end 33 | end 34 | 35 | context 'when manifest name and css path are both specified' do 36 | let(:config_file) { file_fixture('config/manifest-and-path-both-specified.yml').read } 37 | 38 | it 'raises an error' do 39 | expect { subject }.to raise_error LoadError, 'Cannot specify both manifest_name and css_path(s)' 40 | end 41 | end 42 | 43 | context 'when manifest name and css paths are both specified' do 44 | let(:config_file) { file_fixture('config/manifest-and-paths-both-specified.yml').read } 45 | 46 | it 'raises an error' do 47 | expect { subject }.to raise_error LoadError, 'Cannot specify both manifest_name and css_path(s)' 48 | end 49 | end 50 | 51 | context 'when single css_path and multiple css_paths are both specified' do 52 | let(:config_file) { file_fixture('config/paths-both-specified.yml').read } 53 | 54 | it 'raises an error' do 55 | expect { subject }.to raise_error LoadError, 'Cannot specify both css_path and css_paths' 56 | end 57 | end 58 | 59 | context 'when css_paths and routes are not the same length' do 60 | let(:config_file) { file_fixture('config/paths-and-routes-not-same-length.yml').read } 61 | 62 | it 'raises an error' do 63 | expect { subject }.to raise_error LoadError, 'Must specify css_paths for each route' 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/lib/critical_path_css/css_fetcher_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe 'CssFetcher' do 4 | subject { CriticalPathCss::CssFetcher.new(config) } 5 | 6 | let(:base_url) { 'http://0.0.0.0:9292' } 7 | let(:response) { ['foo','', OpenStruct.new(exitstatus: 0)] } 8 | let(:routes) { ['/', '/new_route'] } 9 | let(:config) do 10 | CriticalPathCss::Configuration.new( 11 | OpenStruct.new( 12 | base_url: base_url, 13 | css_paths: css_paths, 14 | penthouse_options: {}, 15 | routes: routes 16 | ) 17 | ) 18 | end 19 | 20 | describe '#fetch_route' do 21 | context 'when a single css_path is configured' do 22 | let(:css_paths) { ['/test.css'] } 23 | 24 | it 'generates css for the single route' do 25 | expect(Open3).to receive(:capture3) do |arg1, arg2, arg3| 26 | options = JSON.parse(arg3) 27 | 28 | expect(options['css']).to eq '/test.css' 29 | end.once.and_return(response) 30 | 31 | subject.fetch_route(routes.first) 32 | end 33 | end 34 | end 35 | 36 | describe '#fetch' do 37 | context 'when a single css_path is configured' do 38 | let(:css_paths) { ['/test.css'] } 39 | 40 | it 'generates css for each route from the same file' do 41 | expect(Open3).to receive(:capture3) do |arg1, arg2, arg3| 42 | options = JSON.parse(arg3) 43 | 44 | expect(options['css']).to eq '/test.css' 45 | end.twice.and_return(response) 46 | 47 | subject.fetch 48 | end 49 | end 50 | 51 | context 'when multiple css_paths are configured' do 52 | let(:css_paths) { ['/test.css', '/test2.css'] } 53 | 54 | it 'generates css for each route from the respective file' do 55 | expect(Open3).to receive(:capture3) do |arg1, arg2, arg3| 56 | options = JSON.parse(arg3) 57 | 58 | css_paths.each_with_index do |path, index| 59 | expect(options['css']).to eq path if options['url'] == "#{base_url}/#{routes[index]}" 60 | end 61 | end.twice.and_return(response) 62 | 63 | subject.fetch 64 | end 65 | end 66 | 67 | context 'when same css file applies to multiple routes' do 68 | let(:css_paths) { ['/test.css', '/test2.css', '/test.css'] } 69 | let(:routes) { ['/', '/new_route', '/newer_route'] } 70 | 71 | it 'generates css for each route from the respective file' do 72 | expect(Open3).to receive(:capture3) do |arg1, arg2, arg3| 73 | options = JSON.parse(arg3) 74 | 75 | css_paths.each_with_index do |path, index| 76 | expect(options['css']).to eq path if options['url'] == "#{base_url}/#{routes[index]}" 77 | end 78 | end.thrice.and_return(response) 79 | 80 | subject.fetch 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # critical-path-css-rails [](https://codeclimate.com/github/mudbugmedia/critical-path-css-rails) 2 | 3 | Only load the CSS you need for the initial viewport in Rails! 4 | 5 | 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. 6 | 7 | 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. 8 | 9 | This gem uses [Penthouse](https://github.com/pocketjoso/penthouse) to generate the critical CSS. 10 | 11 | ## Dependency Requirements for / Upgrading to the Latest Release 12 | 13 | ### For 1.0.0 or later 14 | To maintain the latest version of Penthouse, this gem depends on NodeJS and NVM to be installed on the system. 15 | 16 | ### For 2.0.0 or later 17 | This gem may require additional packages to be installed to run Chrome headless. Per the Penthouse documentation, this may be all you need: 18 | 19 | ``` 20 | sudo apt-get install libnss3 21 | ``` 22 | 23 | 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) 24 | 25 | ## Installation 26 | 27 | After reviewing the dependency requirements, add `critical-path-css-rails` to your Gemfile: 28 | 29 | ``` 30 | gem 'critical-path-css-rails', '~> 3.1.0' 31 | ``` 32 | 33 | Download and install by running: 34 | 35 | ``` 36 | bundle install 37 | ``` 38 | 39 | Run the generator to install the rake task and configuration file: 40 | 41 | ``` 42 | rails generate critical_path_css:install 43 | ``` 44 | 45 | The generator adds the following files: 46 | 47 | * `config/critical_path_css.yml` **Note:** This file supports ERB. 48 | * `lib/tasks/critical_path_css.rake` 49 | 50 | 51 | ## Usage 52 | 53 | First, you'll need to configue a few things in the YAML file: `config/critical_path_css.yml` 54 | 55 | **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. 56 | 57 | * `manifest_name`: If you're using the asset pipeline, add the manifest name. 58 | * `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`. 59 | * `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). 60 | * `routes`: List the routes that you would like to generate the critical CSS for. (i.e. /resources, /resources/show/1, etc.) 61 | * `base_url`: Add your application's URL for the necessary environments. 62 | 63 | 64 | Before generating the CSS, ensure that your application is running (viewable from a browser) and the main CSS file exists. Then in a separate tab, run the rake task to generate the critical CSS. 65 | 66 | If you are using the Asset Pipeline, precompiling the assets will generate the critical CSS after the assets are precompiled. 67 | ``` 68 | rake assets:precompile 69 | ``` 70 | Else you can generate the critical CSS manually using the below task: 71 | ``` 72 | rake critical_path_css:generate 73 | ``` 74 | 75 | 76 | To load the generated critical CSS into your layout, in the head tag, insert: 77 | 78 | ```HTML+ERB 79 | 82 | ``` 83 | 84 | A simple example using [loadcss-rails](https://github.com/michael-misshore/loadcss-rails) looks like: 85 | 86 | ```HTML+ERB 87 | 90 | 93 | 94 | 97 | ``` 98 | 99 | ### Route-level Control of CSS Generation and Removal 100 | 101 | CriticalPathCss exposes some methods to give the user more control over the generation of Critical CSS and managment of the CSS cache: 102 | 103 | ``` ruby 104 | CriticalPathCss.generate route # Generates the critical path CSS for the given route (relative path) 105 | 106 | CriticalPathCss.generate_all # Generates critical CSS for all routes in critical_path_css.yml 107 | 108 | CriticalPathCss.clear route # Removes the CSS for the given route from the cache 109 | 110 | CriticalPathCss.clear_matched routes # Removes the CSS for the matched routes from the cache 111 | ``` 112 | 113 | 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). 114 | 115 | In addition to the `critical_path_css:generate` rake task described above, you also have access to task which clears the CSS cache: 116 | 117 | ``` 118 | rake critical_path_css:clear_all 119 | ``` 120 | NOTE: The `critical_path_css:clear_all` rake task may need to be customized to suit your particular cache implementation. 121 | 122 | 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. 123 | 124 | 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. 125 | 126 | ## Upgrading from a version earlier than 0.3.0 127 | 128 | The latest version of Critcal Path CSS Rails changes the functionality of the `generate` method. In past versions, 129 | `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. 130 | 131 | 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: 132 | 133 | ``` prompt 134 | rails generate critical_path_css:install 135 | ``` 136 | 137 | Answer 'Y' when prompted to overwrite `critical_path_css.rake`. However, overwriting `critical_path_css.yml` is not recommended nor necessary. 138 | 139 | 140 | ## Testing / Development 141 | 142 | 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. 143 | 144 | ```Bash 145 | docker-compose build 146 | docker-compose up -d 147 | docker exec -it $(cat app_container_name) /bin/bash 148 | ``` 149 | 150 | 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/` 151 | 152 | If you encounter Chromium errors trying to run the tests, installing [Puppeteer](https://github.com/GoogleChrome/puppeteer) might help. 153 | 154 | ```Bash 155 | npm install puppeteer 156 | ``` 157 | 158 | 159 | ## Versions 160 | 161 | The critical-path-css-rails gem follows these version guidelines: 162 | 163 | ``` 164 | patch version bump = updates to critical-path-css-rails and patch-level updates to Penthouse 165 | minor version bump = minor-level updates to critical-path-css-rails and Penthouse 166 | major version bump = major-level updates to critical-path-css-rails, Penthouse, and updates to Rails which may be backwards-incompatible 167 | ``` 168 | 169 | ## Contributing 170 | 171 | Feel free to open an issue ticket if you find something that could be improved. 172 | 173 | Copyright Mudbug Media and Michael Misshore, released under the MIT License. 174 | --------------------------------------------------------------------------------