├── test ├── fixtures │ ├── assets │ │ ├── mapped │ │ │ ├── source.css.map │ │ │ ├── source.js.map │ │ │ ├── nested │ │ │ │ ├── another-source.js.map │ │ │ │ ├── sourceMappingURL-already-prefixed-nested.js.map │ │ │ │ ├── another-source.js │ │ │ │ └── sourceMappingURL-already-prefixed-nested.js │ │ │ ├── sourceMappingURL-already-prefixed.js.map │ │ │ ├── sourceMappingURL-not-at-start.css.map │ │ │ ├── sourceMappingURL-not-at-start.js.map │ │ │ ├── sourceMappingURL-already-prefixed-invalid.js.map │ │ │ ├── source.js │ │ │ ├── sourceless.js │ │ │ ├── source.css │ │ │ ├── sourceless.css │ │ │ ├── sourceMappingURL-not-at-start.js │ │ │ ├── sourceMappingURL-already-prefixed.js │ │ │ ├── sourceMappingURL-not-at-end.css │ │ │ ├── sourceMappingURL-not-at-start.css │ │ │ ├── sourceMappingURL-outside-comment.css │ │ │ └── sourceMappingURL-already-prefixed-invalid.js │ │ ├── vendor │ │ │ ├── foobar │ │ │ │ ├── source │ │ │ │ │ ├── file.svg │ │ │ │ │ ├── test.css │ │ │ │ │ ├── file.jpg │ │ │ │ │ ├── database.jpg │ │ │ │ │ ├── http-diagram.jpg │ │ │ │ │ └── images │ │ │ │ │ │ └── file.jpg │ │ │ │ ├── file.jpg │ │ │ │ └── sibling │ │ │ │ │ └── file.jpg │ │ │ └── file.jpg │ │ ├── first_path │ │ │ ├── .stuff │ │ │ ├── file-is-a-sourcemap.js.map │ │ │ ├── one.txt │ │ │ ├── again.js │ │ │ ├── dependent │ │ │ │ ├── a.css │ │ │ │ ├── b.css │ │ │ │ └── c.css │ │ │ ├── nested │ │ │ │ └── three.txt │ │ │ ├── another.css │ │ │ ├── dhh.jpg │ │ │ ├── file-not.digested.css │ │ │ ├── file-already-abcdefVWXYZ0123456789_-.digested.css │ │ │ ├── file-already-abcdefVWXYZ0123456789_-.digested.debug.css │ │ │ └── archive.svg │ │ └── second_path │ │ │ ├── one.txt │ │ │ └── two.txt │ ├── output │ │ ├── one-f2e1ec14.txt │ │ ├── one-f2e1ec15.txt.map │ │ └── .manifest.json │ └── new_manifest_format │ │ ├── one-f2e1ec14.txt │ │ ├── one-f2e1ec15.txt.map │ │ └── .manifest.json ├── dummy │ ├── app │ │ ├── assets │ │ │ ├── javascripts │ │ │ │ └── hello_world.js │ │ │ └── stylesheets │ │ │ │ ├── goodbye.css │ │ │ │ └── hello_world.css │ │ ├── controllers │ │ │ ├── application_controller.rb │ │ │ └── sample_controller.rb │ │ └── views │ │ │ ├── sample │ │ │ ├── load_nonexistent_assets.html.erb │ │ │ └── load_real_assets.html.erb │ │ │ └── layouts │ │ │ └── application.html.erb │ ├── lib │ │ └── assets │ │ │ ├── javascripts │ │ │ └── actioncable.js │ │ │ └── stylesheets │ │ │ └── library.css │ ├── config │ │ ├── routes.rb │ │ ├── environment.rb │ │ ├── boot.rb │ │ ├── application.rb │ │ └── environments │ │ │ ├── development.rb │ │ │ ├── test.rb │ │ │ └── production.rb │ ├── bin │ │ └── rails │ └── config.ru ├── test_helper.rb ├── propshaft │ ├── quiet_assets_test.rb │ ├── compilers_test.rb │ ├── assembly_test.rb │ ├── compiler │ │ ├── js_asset_urls_test.rb │ │ ├── source_mapping_urls_test.rb │ │ └── css_asset_urls_test.rb │ ├── resolver │ │ ├── dynamic_test.rb │ │ └── static_test.rb │ ├── processor_test.rb │ ├── output_path_test.rb │ ├── server_test.rb │ ├── load_path_test.rb │ ├── asset_test.rb │ ├── manifest_test.rb │ └── helper_test.rb └── propshaft_integration_test.rb ├── lib ├── propshaft │ ├── version.rb │ ├── quiet_assets.rb │ ├── errors.rb │ ├── compiler.rb │ ├── resolver │ │ ├── dynamic.rb │ │ └── static.rb │ ├── compilers.rb │ ├── compiler │ │ ├── source_mapping_urls.rb │ │ ├── css_asset_urls.rb │ │ └── js_asset_urls.rb │ ├── railties │ │ └── assets.rake │ ├── output_path.rb │ ├── server.rb │ ├── assembly.rb │ ├── processor.rb │ ├── asset.rb │ ├── railtie.rb │ ├── load_path.rb │ ├── manifest.rb │ └── helper.rb └── propshaft.rb ├── Gemfile ├── bin ├── test └── release ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── gemfiles ├── Gemfile.rails-7.1 ├── Gemfile.rails-7.2 ├── Gemfile.rails-8.0 └── Gemfile.rails-7.0 ├── .gitignore ├── Rakefile ├── propshaft.gemspec ├── benchmarks ├── dynamic_resolver ├── static_resolver ├── css_asset_urls └── trackrod.rb ├── .github └── workflows │ └── ci.yml ├── MIT-LICENSE ├── Gemfile.lock ├── README.md └── UPGRADING.md /test/fixtures/assets/mapped/source.css.map: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/assets/mapped/source.js.map: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/assets/vendor/foobar/source/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/assets/first_path/.stuff: -------------------------------------------------------------------------------- 1 | Won't be included -------------------------------------------------------------------------------- /test/fixtures/assets/first_path/file-is-a-sourcemap.js.map: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/assets/first_path/one.txt: -------------------------------------------------------------------------------- 1 | One from first path -------------------------------------------------------------------------------- /test/fixtures/assets/mapped/nested/another-source.js.map: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/output/one-f2e1ec14.txt: -------------------------------------------------------------------------------- 1 | One from first path -------------------------------------------------------------------------------- /test/fixtures/assets/first_path/again.js: -------------------------------------------------------------------------------- 1 | // This is JS! 2 | -------------------------------------------------------------------------------- /test/fixtures/assets/second_path/one.txt: -------------------------------------------------------------------------------- 1 | One from second path -------------------------------------------------------------------------------- /test/fixtures/assets/second_path/two.txt: -------------------------------------------------------------------------------- 1 | Two from second path -------------------------------------------------------------------------------- /test/fixtures/assets/mapped/sourceMappingURL-already-prefixed.js.map: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/assets/mapped/sourceMappingURL-not-at-start.css.map: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/assets/mapped/sourceMappingURL-not-at-start.js.map: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/output/one-f2e1ec15.txt.map: -------------------------------------------------------------------------------- 1 | One from first path map -------------------------------------------------------------------------------- /test/fixtures/assets/first_path/dependent/a.css: -------------------------------------------------------------------------------- 1 | @import url('b.css') 2 | -------------------------------------------------------------------------------- /test/fixtures/assets/first_path/nested/three.txt: -------------------------------------------------------------------------------- 1 | Three from first path -------------------------------------------------------------------------------- /test/fixtures/new_manifest_format/one-f2e1ec14.txt: -------------------------------------------------------------------------------- 1 | One from first path -------------------------------------------------------------------------------- /test/fixtures/output/.manifest.json: -------------------------------------------------------------------------------- 1 | { "one.txt": "one-f2e1ec14.txt" } 2 | -------------------------------------------------------------------------------- /lib/propshaft/version.rb: -------------------------------------------------------------------------------- 1 | module Propshaft 2 | VERSION = "1.3.1" 3 | end 4 | -------------------------------------------------------------------------------- /test/fixtures/assets/mapped/sourceMappingURL-already-prefixed-invalid.js.map: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/new_manifest_format/one-f2e1ec15.txt.map: -------------------------------------------------------------------------------- 1 | One from first path map -------------------------------------------------------------------------------- /test/dummy/app/assets/javascripts/hello_world.js: -------------------------------------------------------------------------------- 1 | console.log("Hello world!") 2 | -------------------------------------------------------------------------------- /test/dummy/lib/assets/javascripts/actioncable.js: -------------------------------------------------------------------------------- 1 | console.log("actioncable.js") 2 | -------------------------------------------------------------------------------- /test/fixtures/assets/mapped/nested/sourceMappingURL-already-prefixed-nested.js.map: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/assets/stylesheets/goodbye.css: -------------------------------------------------------------------------------- 1 | h2:after { 2 | content: "Goodbye!"; 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/assets/mapped/source.js: -------------------------------------------------------------------------------- 1 | var fun; 2 | //# sourceMappingURL=source.js.map 3 | -------------------------------------------------------------------------------- /test/fixtures/assets/vendor/foobar/source/test.css: -------------------------------------------------------------------------------- 1 | .hero { background: url(file.jpg) } 2 | -------------------------------------------------------------------------------- /test/dummy/app/assets/stylesheets/hello_world.css: -------------------------------------------------------------------------------- 1 | h1:after { 2 | content: " Hello world!"; 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/assets/first_path/dependent/b.css: -------------------------------------------------------------------------------- 1 | @import url('c.css') 2 | @import url('missing.css') 3 | -------------------------------------------------------------------------------- /test/fixtures/assets/mapped/sourceless.js: -------------------------------------------------------------------------------- 1 | var failure; 2 | //# sourceMappingURL=sourceless.js.map 3 | -------------------------------------------------------------------------------- /test/fixtures/assets/first_path/dependent/c.css: -------------------------------------------------------------------------------- 1 | @import url('a.css') 2 | 3 | p { 4 | color: red; 5 | } -------------------------------------------------------------------------------- /test/fixtures/assets/mapped/source.css: -------------------------------------------------------------------------------- 1 | .class { color: green; } 2 | /*# sourceMappingURL=source.css.map */ 3 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /test/fixtures/assets/mapped/nested/another-source.js: -------------------------------------------------------------------------------- 1 | var extraFun; 2 | //# sourceMappingURL=another-source.js.map 3 | -------------------------------------------------------------------------------- /test/fixtures/assets/mapped/sourceless.css: -------------------------------------------------------------------------------- 1 | .failure { color: red; } 2 | /*# sourceMappingURL=sourceless.css.map */ 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "rails", ">= 7.0.1" 6 | gem "rake" 7 | gem "debug" 8 | -------------------------------------------------------------------------------- /test/fixtures/assets/vendor/file.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rails/propshaft/main/test/fixtures/assets/vendor/file.jpg -------------------------------------------------------------------------------- /test/dummy/lib/assets/stylesheets/library.css: -------------------------------------------------------------------------------- 1 | # Library css that should not be included by :app but should be included by :all 2 | -------------------------------------------------------------------------------- /test/fixtures/assets/first_path/another.css: -------------------------------------------------------------------------------- 1 | /* this is css */ 2 | 3 | .btn { 4 | background-image: url("archive.svg"); 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/assets/first_path/dhh.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rails/propshaft/main/test/fixtures/assets/first_path/dhh.jpg -------------------------------------------------------------------------------- /test/fixtures/assets/mapped/sourceMappingURL-not-at-start.js: -------------------------------------------------------------------------------- 1 | var fun; //# sourceMappingURL=sourceMappingURL-not-at-start.js.map 2 | 3 | -------------------------------------------------------------------------------- /test/fixtures/assets/vendor/foobar/file.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rails/propshaft/main/test/fixtures/assets/vendor/foobar/file.jpg -------------------------------------------------------------------------------- /test/fixtures/assets/mapped/sourceMappingURL-already-prefixed.js: -------------------------------------------------------------------------------- 1 | var fun; //# sourceMappingURL=/assets/sourceMappingURL-already-prefixed.js.map -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $: << File.expand_path("../test", __dir__) 3 | 4 | require "bundler/setup" 5 | require "rails/plugin/test" 6 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # [Choice] Ruby version: 3.4, 3.3, 3.2 2 | ARG VARIANT="3.4.4" 3 | FROM ghcr.io/rails/devcontainer/images/ruby:${VARIANT} 4 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.rails-7.1: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: ".." 4 | 5 | gem "rails", "~> 7.1.0" 6 | gem "rake" 7 | gem "debug" 8 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.rails-7.2: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: ".." 4 | 5 | gem "rails", "~> 7.2.0" 6 | gem "rake" 7 | gem "debug" 8 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.rails-8.0: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: ".." 4 | 5 | gem "rails", "~> 8.0" 6 | gem "rake" 7 | gem "debug" 8 | -------------------------------------------------------------------------------- /test/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | get 'sample/load_real_assets' 3 | get 'sample/load_nonexistent_assets' 4 | end 5 | -------------------------------------------------------------------------------- /test/fixtures/assets/first_path/file-not.digested.css: -------------------------------------------------------------------------------- 1 | /* this is css */ 2 | 3 | .btn { 4 | background-image: asset-path("archive.svg"); 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/assets/vendor/foobar/sibling/file.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rails/propshaft/main/test/fixtures/assets/vendor/foobar/sibling/file.jpg -------------------------------------------------------------------------------- /test/fixtures/assets/vendor/foobar/source/file.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rails/propshaft/main/test/fixtures/assets/vendor/foobar/source/file.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore all logfiles and tempfiles. 2 | /test/dummy/log/* 3 | /test/dummy/tmp/* 4 | 5 | # Ignore compiled assets 6 | /test/dummy/public/assets 7 | -------------------------------------------------------------------------------- /test/fixtures/assets/mapped/sourceMappingURL-not-at-end.css: -------------------------------------------------------------------------------- 1 | .class { 2 | /*# sourceMappingURL=sourceMappingURL-not-at-end.css.map */ 3 | color: green; 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/assets/mapped/sourceMappingURL-not-at-start.css: -------------------------------------------------------------------------------- 1 | .class{color:green;}/*# sourceMappingURL=sourceMappingURL-not-at-start.css.map */ 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /test/fixtures/assets/vendor/foobar/source/database.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rails/propshaft/main/test/fixtures/assets/vendor/foobar/source/database.jpg -------------------------------------------------------------------------------- /test/fixtures/assets/mapped/nested/sourceMappingURL-already-prefixed-nested.js: -------------------------------------------------------------------------------- 1 | var fun; //# sourceMappingURL=/assets/sourceMappingURL-already-prefixed-nested.js.map 2 | -------------------------------------------------------------------------------- /test/fixtures/assets/vendor/foobar/source/http-diagram.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rails/propshaft/main/test/fixtures/assets/vendor/foobar/source/http-diagram.jpg -------------------------------------------------------------------------------- /test/fixtures/assets/vendor/foobar/source/images/file.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rails/propshaft/main/test/fixtures/assets/vendor/foobar/source/images/file.jpg -------------------------------------------------------------------------------- /test/fixtures/assets/mapped/sourceMappingURL-outside-comment.css: -------------------------------------------------------------------------------- 1 | .class::before { 2 | content: "# sourceMappingURL=sourceMappingURL-outside-comment.css.map"; 3 | } 4 | -------------------------------------------------------------------------------- /test/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /test/fixtures/assets/first_path/file-already-abcdefVWXYZ0123456789_-.digested.css: -------------------------------------------------------------------------------- 1 | /* this is css */ 2 | 3 | .btn { 4 | background-image: asset-path("archive.svg"); 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/assets/mapped/sourceMappingURL-already-prefixed-invalid.js: -------------------------------------------------------------------------------- 1 | var fun; //# sourceMappingURL=thisisinvalidassets/sourceMappingURL-already-prefixed-invalid.js.map 2 | -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.rails-7.0: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: ".." 4 | 5 | gem "rails", github: "rails/rails", branch: "7-0-stable" 6 | gem "rake" 7 | gem "debug" 8 | -------------------------------------------------------------------------------- /test/fixtures/assets/first_path/file-already-abcdefVWXYZ0123456789_-.digested.debug.css: -------------------------------------------------------------------------------- 1 | /* this is css */ 2 | 3 | .btn { 4 | background-image: asset-path("archive.svg"); 5 | } 6 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/sample_controller.rb: -------------------------------------------------------------------------------- 1 | class SampleController < ApplicationController 2 | def load_real_assets 3 | end 4 | 5 | def load_nonexistent_assets 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/fixtures/new_manifest_format/.manifest.json: -------------------------------------------------------------------------------- 1 | {"one.txt": {"digested_path": "one-f2e1ec14.txt","integrity": "sha384-LdS8l2QTAF8bD8WPb8QSQv0skTWHhmcnS2XU5LBkVQneGzqIqnDRskQtJvi7ADMe"}} 2 | -------------------------------------------------------------------------------- /test/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) 3 | 4 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 5 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) 6 | -------------------------------------------------------------------------------- /test/dummy/app/views/sample/load_nonexistent_assets.html.erb: -------------------------------------------------------------------------------- 1 | <%= stylesheet_link_tag "nonexistent" %> 2 | 3 |

Sample#load_nonexistent_assets

4 |

Find me in app/views/sample/load_nonexistent_assets.html.erb

5 | 6 | <%= javascript_include_tag "nonexistent" %> 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "bundler/gem_tasks" 3 | require "rake/testtask" 4 | 5 | Rake::TestTask.new do |test| 6 | test.libs << "test" 7 | test.test_files = FileList["test/**/*_test.rb"] 8 | test.warning = true 9 | end 10 | 11 | task default: :test 12 | -------------------------------------------------------------------------------- /test/dummy/app/views/sample/load_real_assets.html.erb: -------------------------------------------------------------------------------- 1 | <%= stylesheet_link_tag "hello_world", integrity: true %> 2 | 3 |

Sample#load_real_assets

4 |

Find me in app/views/sample/load_real_assets.html.erb

5 | 6 | <%= javascript_include_tag "hello_world", integrity: true %> 7 | <%= javascript_include_tag "actioncable" %> 8 | -------------------------------------------------------------------------------- /test/fixtures/assets/first_path/archive.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /lib/propshaft/quiet_assets.rb: -------------------------------------------------------------------------------- 1 | class Propshaft::QuietAssets 2 | def initialize(app) 3 | @app = app 4 | @assets_regex = %r(\A/{0,2}#{::Rails.application.config.assets.prefix}) 5 | end 6 | 7 | def call(env) 8 | if env['PATH_INFO'] =~ @assets_regex 9 | ::Rails.logger.silence { @app.call(env) } 10 | else 11 | @app.call(env) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | <%= csrf_meta_tags %> 6 | <%= csp_meta_tag %> 7 | 8 | <%= stylesheet_link_tag :all, data: { custom_attribute: true } %> 9 | <%= stylesheet_link_tag :app, data: { glob_attribute: true } %> 10 | 11 | 12 | 13 | <%= yield %> 14 | 15 | 16 | -------------------------------------------------------------------------------- /lib/propshaft.rb: -------------------------------------------------------------------------------- 1 | require "active_support" 2 | require "active_support/core_ext/module/attribute_accessors" 3 | require "active_support/core_ext/module/delegation" 4 | require "logger" 5 | 6 | module Propshaft 7 | mattr_accessor :logger, default: Logger.new(STDOUT) 8 | end 9 | 10 | require "propshaft/assembly" 11 | require "propshaft/errors" 12 | require "propshaft/helper" 13 | require "propshaft/railtie" if defined?(Rails::Railtie) 14 | -------------------------------------------------------------------------------- /lib/propshaft/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Propshaft 4 | # Generic base class for all Propshaft exceptions. 5 | class Error < StandardError; end 6 | 7 | # Raised when LoadPath cannot find the requested asset 8 | class MissingAssetError < Error 9 | def initialize(path) 10 | super 11 | @path = path 12 | end 13 | 14 | def message 15 | "The asset '#{@path}' was not found in the load path." 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /bin/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | VERSION=$1 4 | 5 | if [ -z "$VERSION" ]; then 6 | echo "Usage: $0 " 7 | exit 1 8 | fi 9 | 10 | printf "module Propshaft\n VERSION = \"$VERSION\"\nend\n" > ./lib/propshaft/version.rb 11 | bundle 12 | git add Gemfile.lock lib/propshaft/version.rb 13 | git commit -m "Bump version for $VERSION" 14 | git push 15 | git tag v$VERSION 16 | git push --tags 17 | gem build propshaft.gemspec 18 | gem push "propshaft-$VERSION.gem" --host https://rubygems.org 19 | rm "propshaft-$VERSION.gem" 20 | -------------------------------------------------------------------------------- /lib/propshaft/compiler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Base compiler from which other compilers can inherit 4 | class Propshaft::Compiler 5 | attr_reader :assembly 6 | delegate :config, :load_path, to: :assembly 7 | 8 | def initialize(assembly) 9 | @assembly = assembly 10 | end 11 | 12 | # Override this in a specific compiler 13 | def compile(asset, input) 14 | raise NotImplementedError 15 | end 16 | 17 | def referenced_by(asset) 18 | Set.new 19 | end 20 | 21 | private 22 | def url_prefix 23 | @url_prefix ||= File.join(config.relative_url_root.to_s, config.prefix.to_s).chomp("/") 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /propshaft.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/propshaft/version" 2 | 3 | Gem::Specification.new do |s| 4 | s.name = "propshaft" 5 | s.version = Propshaft::VERSION 6 | s.authors = [ "David Heinemeier Hansson" ] 7 | s.email = "dhh@hey.com" 8 | s.summary = "Deliver assets for Rails." 9 | s.homepage = "https://github.com/rails/propshaft" 10 | s.license = "MIT" 11 | 12 | s.metadata = { 13 | "rubygems_mfa_required" => "true" 14 | } 15 | 16 | s.required_ruby_version = ">= 2.7.0" 17 | s.add_dependency "actionpack", ">= 7.0.0" 18 | s.add_dependency "activesupport", ">= 7.0.0" 19 | s.add_dependency "rack" 20 | 21 | s.files = Dir["lib/**/*", "MIT-LICENSE", "Rakefile", "README.md"] 22 | end 23 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Configure Rails Environment 2 | ENV["RAILS_ENV"] = "test" 3 | Warning[:deprecated] = true 4 | $VERBOSE = true 5 | 6 | require_relative "../test/dummy/config/environment" 7 | require "rails/test_help" 8 | 9 | require "rails/test_unit/reporter" 10 | Rails::TestUnitReporter.executable = "bin/test" 11 | 12 | class ActiveSupport::TestCase 13 | private 14 | def find_asset(logical_path, fixture_path:) 15 | root_path = Pathname.new("#{__dir__}/fixtures/assets/#{fixture_path}") 16 | path = root_path.join(logical_path) 17 | load_path = Propshaft::LoadPath.new([ root_path ], compilers: Propshaft::Compilers.new(nil)) 18 | 19 | Propshaft::Asset.new(path, logical_path: logical_path, load_path: load_path) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /benchmarks/dynamic_resolver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # Benchmark file for the Dynamic resolver 4 | 5 | require "active_support/ordered_options" 6 | require "benchmark/ips" 7 | require "open-uri" 8 | 9 | require_relative "./trackrod" 10 | require_relative "../lib/propshaft" 11 | 12 | trackrod = Trackrod.new(Dir.mktmpdir) 13 | trackrod.build 14 | 15 | assets = ActiveSupport::OrderedOptions.new 16 | assets.paths = [ trackrod.root ] 17 | assets.prefix = "/assets" 18 | assets.compilers = [ [ "text/css", Propshaft::Compiler::CssAssetUrls ] ] 19 | assets.output_path ||= Pathname.new(Dir.mktmpdir) 20 | 21 | assembly = Propshaft::Assembly.new(assets) 22 | 23 | Benchmark.ips do |x| 24 | x.config(time: 5, warmup: 2) 25 | x.report("compile") { trackrod.assets.images.each { assembly.resolver.resolve _1 } } 26 | end 27 | -------------------------------------------------------------------------------- /benchmarks/static_resolver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # Benchmark file for the static resolver 4 | 5 | require "active_support/core_ext/string/access" 6 | require "active_support/ordered_options" 7 | require "benchmark/ips" 8 | 9 | require_relative "./trackrod" 10 | require_relative "../lib/propshaft" 11 | 12 | trackrod = Trackrod.new(Dir.mktmpdir) 13 | trackrod.build 14 | 15 | assets = ActiveSupport::OrderedOptions.new 16 | assets.paths = [ trackrod.root ] 17 | assets.prefix = "/assets" 18 | assets.compilers = [ [ "text/css", Propshaft::Compiler::CssAssetUrls ] ] 19 | assets.output_path ||= Pathname.new(Dir.mktmpdir) 20 | 21 | assembly = Propshaft::Assembly.new(assets) 22 | assembly.processor.process 23 | 24 | Benchmark.ips do |x| 25 | x.config(time: 5, warmup: 2) 26 | x.report("compile") { trackrod.assets.images.each { assembly.resolver.resolve _1 } } 27 | end 28 | -------------------------------------------------------------------------------- /lib/propshaft/resolver/dynamic.rb: -------------------------------------------------------------------------------- 1 | module Propshaft::Resolver 2 | class Dynamic 3 | attr_reader :load_path, :prefix 4 | 5 | def initialize(load_path:, prefix:) 6 | @load_path, @prefix = load_path, prefix 7 | end 8 | 9 | def resolve(logical_path) 10 | if asset = find_asset(logical_path) 11 | File.join prefix, asset.digested_path 12 | end 13 | end 14 | 15 | def integrity(logical_path) 16 | hash_algorithm = load_path.integrity_hash_algorithm 17 | 18 | if hash_algorithm && (asset = find_asset(logical_path)) 19 | asset.integrity(hash_algorithm: hash_algorithm) 20 | end 21 | end 22 | 23 | def read(logical_path, options = {}) 24 | if asset = load_path.find(logical_path) 25 | asset.content(**options) 26 | end 27 | end 28 | 29 | private 30 | def find_asset(logical_path) 31 | load_path.find(logical_path) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /benchmarks/css_asset_urls: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # Benchmark file for the CssAssetUrls compiler 4 | 5 | require "active_support/ordered_options" 6 | require "benchmark/ips" 7 | require "open-uri" 8 | 9 | require_relative "./trackrod" 10 | require_relative "../lib/propshaft" 11 | require_relative "../lib/propshaft/compilers" 12 | require_relative "../lib/propshaft/compiler/css_asset_urls" 13 | 14 | trackrod = Trackrod.new(Dir.mktmpdir) 15 | trackrod.build 16 | 17 | assets = ActiveSupport::OrderedOptions.new 18 | assets.paths = [ trackrod.root ] 19 | assets.prefix = "/assets" 20 | assets.compilers = [ [ "text/css", Propshaft::Compiler::CssAssetUrls ] ] 21 | 22 | assembly = Propshaft::Assembly.new(assets) 23 | asset = assembly.load_path.find(trackrod.assets.css) 24 | compiler = Propshaft::Compiler::CssAssetUrls.new(assembly) 25 | 26 | Benchmark.ips do |x| 27 | x.config(time: 5, warmup: 2) 28 | x.report("compile") { compiler.compile(asset, asset.content) } 29 | end 30 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | tests: 5 | strategy: 6 | matrix: 7 | ruby-version: 8 | - "3.1" 9 | - "3.2" 10 | - "3.3" 11 | - "3.4" 12 | rails-version: 13 | - "7.0" 14 | - "7.1" 15 | - "7.2" 16 | - "8.0" 17 | exclude: 18 | - ruby-version: "3.1" 19 | rails-version: "8.0" 20 | fail-fast: false 21 | env: 22 | BUNDLE_GEMFILE: gemfiles/Gemfile.rails-${{ matrix.rails-version }} 23 | 24 | name: ${{ format('Tests (Ruby {0}) (Rails {1})', matrix.ruby-version, matrix.rails-version) }} 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - name: Install Ruby 31 | uses: ruby/setup-ruby@v1 32 | with: 33 | ruby-version: ${{ matrix.ruby-version }} 34 | bundler-cache: true 35 | 36 | - name: Run tests 37 | run: bin/test 38 | -------------------------------------------------------------------------------- /test/propshaft/quiet_assets_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "propshaft/quiet_assets" 3 | 4 | class Propshaft::QuietAssetsTest < ActiveSupport::TestCase 5 | setup do 6 | Rails.logger.level = Logger::DEBUG 7 | end 8 | 9 | test "silences with default prefix" do 10 | assert_equal Logger::ERROR, middleware.call("PATH_INFO" => "/assets/stylesheets/application.css") 11 | end 12 | 13 | test "silences with custom prefix" do 14 | original = Rails.application.config.assets.prefix 15 | Rails.application.config.assets.prefix = "path/to" 16 | assert_equal Logger::ERROR, middleware.call("PATH_INFO" => "/path/to/thing") 17 | ensure 18 | Rails.application.config.assets.prefix = original 19 | end 20 | 21 | test "does not silence without match" do 22 | assert_equal Logger::DEBUG, middleware.call("PATH_INFO" => "/path/to/thing") 23 | end 24 | 25 | private 26 | 27 | def middleware 28 | @middleware ||= Propshaft::QuietAssets.new(->(env) { Rails.logger.level }) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/propshaft/resolver/static.rb: -------------------------------------------------------------------------------- 1 | module Propshaft::Resolver 2 | class Static 3 | attr_reader :manifest_path, :prefix 4 | 5 | def initialize(manifest_path:, prefix:) 6 | @manifest_path, @prefix = manifest_path, prefix 7 | end 8 | 9 | def resolve(logical_path) 10 | if asset_path = digested_path(logical_path) 11 | File.join prefix, asset_path 12 | end 13 | end 14 | 15 | def integrity(logical_path) 16 | entry = manifest[logical_path] 17 | 18 | entry&.integrity 19 | end 20 | 21 | def read(logical_path, encoding: "ASCII-8BIT") 22 | if asset_path = digested_path(logical_path) 23 | File.read(manifest_path.dirname.join(asset_path), encoding: encoding) 24 | end 25 | end 26 | 27 | private 28 | def manifest 29 | @manifest ||= Propshaft::Manifest.from_path(manifest_path) 30 | end 31 | 32 | def digested_path(logical_path) 33 | entry = manifest[logical_path] 34 | 35 | entry&.digested_path 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.205.2/containers/ruby 3 | { 4 | "name": "Propshaft", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | "args": { 8 | // Update 'VARIANT' to pick a Ruby version: 3, 3.0, 2, 2.7, 2.6 9 | // Append -bullseye or -buster to pin to an OS version. 10 | // Use -bullseye variants on local on arm64/Apple Silicon. 11 | "VARIANT": "3.4.4", 12 | } 13 | }, 14 | 15 | // Configure tool-specific properties. 16 | // "customizations": {}, 17 | 18 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 19 | // "forwardPorts": [], 20 | 21 | // Use 'postCreateCommand' to run commands after the container is created. 22 | // "postCreateCommand": "ruby --version", 23 | 24 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 25 | "remoteUser": "vscode", 26 | "features": { 27 | "github-cli": "latest" 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Basecamp 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /test/propshaft/compilers_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "propshaft/asset" 3 | require "propshaft/assembly" 4 | require "propshaft/compilers" 5 | 6 | class Propshaft::CompilersTest < ActiveSupport::TestCase 7 | setup do 8 | @assembly = Propshaft::Assembly.new(ActiveSupport::OrderedOptions.new.tap { |config| 9 | config.paths = [ Pathname.new("#{__dir__}/../fixtures/assets/first_path") ] 10 | config.output_path = Pathname.new("#{__dir__}/../fixtures/output") 11 | config.prefix = "/assets" 12 | }) 13 | end 14 | 15 | test "replace asset-path function in css with digested url" do 16 | @assembly.compilers.register "text/css", Propshaft::Compiler::CssAssetUrls 17 | assert_match(/"\/assets\/archive-[a-z0-9]{8}.svg/, @assembly.compilers.compile(find_asset("another.css"))) 18 | end 19 | 20 | private 21 | def find_asset(logical_path) 22 | root_path = Pathname.new("#{__dir__}/../fixtures/assets/first_path") 23 | load_path = Propshaft::LoadPath.new([ root_path ], compilers: Propshaft::Compilers.new(nil)) 24 | Propshaft::Asset.new(root_path.join(logical_path), logical_path: logical_path, load_path: load_path) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/propshaft/compilers.rb: -------------------------------------------------------------------------------- 1 | class Propshaft::Compilers 2 | attr_reader :registrations, :assembly 3 | 4 | def initialize(assembly) 5 | @assembly = assembly 6 | @registrations = Hash.new 7 | end 8 | 9 | def register(mime_type, klass) 10 | registrations[mime_type] ||= [] 11 | registrations[mime_type] << klass 12 | end 13 | 14 | def any? 15 | registrations.any? 16 | end 17 | 18 | def compilable?(asset) 19 | registrations[asset.content_type.to_s].present? 20 | end 21 | 22 | def compile(asset) 23 | if relevant_registrations = registrations[asset.content_type.to_s] 24 | asset.content.dup.tap do |input| 25 | relevant_registrations.each do |compiler| 26 | input.replace compiler.new(assembly).compile(asset, input) 27 | end 28 | end 29 | else 30 | asset.content 31 | end 32 | end 33 | 34 | def referenced_by(asset) 35 | Set.new.tap do |references| 36 | if relevant_registrations = registrations[asset.content_type.to_s] 37 | relevant_registrations.each do |compiler| 38 | references.merge compiler.new(assembly).referenced_by(asset) 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/propshaft/compiler/source_mapping_urls.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "propshaft/compiler" 4 | 5 | class Propshaft::Compiler::SourceMappingUrls < Propshaft::Compiler 6 | SOURCE_MAPPING_PATTERN = %r{(//|/\*)# sourceMappingURL=(.+\.map)(\s*?\*\/)?\s*?\Z} 7 | 8 | def compile(asset, input) 9 | input.gsub(SOURCE_MAPPING_PATTERN) { source_mapping_url(asset.logical_path, asset_path($2, asset.logical_path), $1, $3) } 10 | end 11 | 12 | private 13 | def asset_path(source_mapping_url, logical_path) 14 | source_mapping_url.gsub!(/^(.+\/)?#{url_prefix}\//, "") 15 | 16 | if logical_path.dirname.to_s == "." 17 | source_mapping_url 18 | else 19 | logical_path.dirname.join(source_mapping_url).to_s 20 | end 21 | end 22 | 23 | def source_mapping_url(logical_path, resolved_path, comment_start, comment_end) 24 | if asset = load_path.find(resolved_path) 25 | "#{comment_start}# sourceMappingURL=#{url_prefix}/#{asset.digested_path}#{comment_end}" 26 | else 27 | Propshaft.logger.warn "Removed sourceMappingURL comment for missing asset '#{resolved_path}' from #{logical_path}" 28 | "#{comment_start}#{comment_end}" 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/propshaft/railties/assets.rake: -------------------------------------------------------------------------------- 1 | namespace :assets do 2 | desc "Compile all the assets from config.assets.paths" 3 | task precompile: :environment do 4 | Rails.application.assets.processor.process 5 | if Rails.env.development? 6 | puts "Warning: You are precompiling assets in development. Rails will not " \ 7 | "serve any changed assets until you delete public#{Rails.application.config.assets.prefix}/.manifest.json" 8 | end 9 | end 10 | 11 | desc "Remove config.assets.output_path" 12 | task clobber: :environment do 13 | Rails.application.assets.processor.clobber 14 | end 15 | 16 | desc "Removes old files in config.assets.output_path" 17 | task :clean, [:count] => [:environment] do |_, args| 18 | count = args.fetch(:count, 2) 19 | Rails.application.assets.processor.clean(count.to_i) 20 | end 21 | 22 | desc "Print all the assets available in config.assets.paths" 23 | task reveal: :environment do 24 | puts Rails.application.assets.reveal(:logical_path).join("\n") 25 | end 26 | 27 | namespace :reveal do 28 | desc "Print the full path of assets available in config.assets.paths" 29 | task full: :environment do 30 | puts Rails.application.assets.reveal(:path).join("\n") 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails" 4 | # Pick the frameworks you want: 5 | # require "active_model/railtie" 6 | # require "active_job/railtie" 7 | # require "active_record/railtie" 8 | # require "active_storage/engine" 9 | require "action_controller/railtie" 10 | # require "action_mailer/railtie" 11 | # require "action_mailbox/engine" 12 | # require "action_text/engine" 13 | require "action_view/railtie" 14 | require "action_cable/engine" 15 | # require "sprockets/railtie" 16 | require "rails/test_unit/railtie" 17 | 18 | # Require the gems listed in Gemfile, including any gems 19 | # you've limited to :test, :development, or :production. 20 | Bundler.require(*Rails.groups) 21 | require "propshaft" 22 | 23 | module Dummy 24 | class Application < Rails::Application 25 | config.load_defaults Rails::VERSION::STRING.to_f 26 | 27 | # Configuration for the application, engines, and railties goes here. 28 | # 29 | # These settings can be overridden in specific environments using the files 30 | # in config/environments, which are processed later. 31 | # 32 | # config.time_zone = "Central Time (US & Canada)" 33 | # config.eager_load_paths << Rails.root.join("extras") 34 | 35 | config.assets.integrity_hash_algorithm = "sha384" 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/propshaft/compiler/css_asset_urls.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "propshaft/compiler" 4 | 5 | class Propshaft::Compiler::CssAssetUrls < Propshaft::Compiler 6 | ASSET_URL_PATTERN = /url\(\s*["']?(?!(?:\#|%23|data:|http:|https:|\/\/))([^"'\s?#)]+)([#?][^"')]+)?\s*["']?\)/ 7 | 8 | def compile(asset, input) 9 | input.gsub(ASSET_URL_PATTERN) { asset_url resolve_path(asset.logical_path.dirname, $1), asset.logical_path, $2, $1 } 10 | end 11 | 12 | def referenced_by(asset, references: Set.new) 13 | asset.content.scan(ASSET_URL_PATTERN).each do |referenced_asset_url, _| 14 | referenced_asset = load_path.find(resolve_path(asset.logical_path.dirname, referenced_asset_url)) 15 | 16 | if referenced_asset && references.exclude?(referenced_asset) 17 | references << referenced_asset 18 | references.merge referenced_by(referenced_asset, references: references) 19 | end 20 | end 21 | 22 | references 23 | end 24 | 25 | private 26 | def resolve_path(directory, filename) 27 | if filename.start_with?("../") 28 | Pathname.new(directory + filename).relative_path_from("").to_s 29 | elsif filename.start_with?("/") 30 | filename.delete_prefix("/").to_s 31 | else 32 | (directory + filename.delete_prefix("./")).to_s 33 | end 34 | end 35 | 36 | def asset_url(resolved_path, logical_path, fingerprint, pattern) 37 | if asset = load_path.find(resolved_path) 38 | %[url("#{url_prefix}/#{asset.digested_path}#{fingerprint}")] 39 | else 40 | Propshaft.logger.warn "Unable to resolve '#{pattern}' for missing asset '#{resolved_path}' in #{logical_path}" 41 | %[url("#{pattern}")] 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/propshaft/compiler/js_asset_urls.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "propshaft/compiler" 4 | 5 | class Propshaft::Compiler::JsAssetUrls < Propshaft::Compiler 6 | ASSET_URL_PATTERN = %r{RAILS_ASSET_URL\(\s*["']?(?!(?:\#|%23|data|http|//))([^"'\s?#)]+)([#?][^"')]+)?\s*["']?\)} 7 | 8 | def compile(asset, input) 9 | input.gsub(ASSET_URL_PATTERN) { asset_url(resolve_path(asset.logical_path.dirname, $1), asset.logical_path, $2, $1) } 10 | end 11 | 12 | def referenced_by(asset, references: Set.new) 13 | asset.content.scan(ASSET_URL_PATTERN).each do |referenced_asset_url, _| 14 | referenced_asset = load_path.find(resolve_path(asset.logical_path.dirname, referenced_asset_url)) 15 | 16 | if referenced_asset && references.exclude?(referenced_asset) 17 | references << referenced_asset 18 | references.merge referenced_by(referenced_asset, references: references) 19 | end 20 | end 21 | 22 | references 23 | end 24 | 25 | private 26 | def resolve_path(directory, filename) 27 | if filename.start_with?("../") 28 | Pathname.new(directory + filename).relative_path_from("").to_s 29 | elsif filename.start_with?("/") 30 | filename.delete_prefix("/").to_s 31 | else 32 | (directory + filename.delete_prefix("./")).to_s 33 | end 34 | end 35 | 36 | def asset_url(resolved_path, logical_path, fingerprint, pattern) 37 | asset = load_path.find(resolved_path) 38 | if asset 39 | %["#{url_prefix}/#{asset.digested_path}#{fingerprint}"] 40 | else 41 | Propshaft.logger.warn("Unable to resolve '#{pattern}' for missing asset '#{resolved_path}' in #{logical_path}") 42 | %["#{pattern}"] 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/propshaft/output_path.rb: -------------------------------------------------------------------------------- 1 | require "propshaft/asset" 2 | 3 | class Propshaft::OutputPath 4 | attr_reader :path, :manifest 5 | 6 | def initialize(path, manifest) 7 | @path, @manifest = path, manifest 8 | end 9 | 10 | def clean(count, age) 11 | asset_versions = files.group_by { |_, attrs| attrs[:logical_path] } 12 | asset_versions.each do |logical_path, versions| 13 | current = manifest[logical_path] 14 | 15 | versions 16 | .reject { |path, _| current && path == current } 17 | .sort_by { |_, attrs| attrs[:mtime] } 18 | .reverse 19 | .each_with_index 20 | .drop_while { |(_, attrs), index| fresh_version_within_limit(attrs[:mtime], count, expires_at: age, limit: index) } 21 | .each { |(path, _), _| remove(path) } 22 | end 23 | end 24 | 25 | def files 26 | Hash.new.tap do |files| 27 | all_files_from_tree(path).each do |file| 28 | digested_path = file.relative_path_from(path) 29 | logical_path, digest = Propshaft::Asset.extract_path_and_digest(digested_path.to_s) 30 | 31 | files[digested_path.to_s] = { 32 | logical_path: logical_path.to_s, 33 | digest: digest, 34 | mtime: File.mtime(file) 35 | } 36 | end 37 | end 38 | end 39 | 40 | private 41 | def fresh_version_within_limit(mtime, count, expires_at:, limit:) 42 | modified_at = [ 0, Time.now - mtime ].max 43 | modified_at < expires_at || limit < count 44 | end 45 | 46 | def remove(path) 47 | FileUtils.rm(@path.join(path)) 48 | Propshaft.logger.info "Removed #{path}" 49 | end 50 | 51 | def all_files_from_tree(path) 52 | path.children.flat_map { |child| child.directory? ? all_files_from_tree(child) : child } 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/propshaft/server.rb: -------------------------------------------------------------------------------- 1 | require "rack/utils" 2 | require "rack/version" 3 | 4 | class Propshaft::Server 5 | def initialize(app, assembly) 6 | @app = app 7 | @assembly = assembly 8 | end 9 | 10 | def call(env) 11 | execute_cache_sweeper_if_updated 12 | 13 | path = env["PATH_INFO"] 14 | method = env["REQUEST_METHOD"] 15 | 16 | if (method == "GET" || method == "HEAD") && path.start_with?(@assembly.prefix) 17 | path, digest = extract_path_and_digest(path) 18 | 19 | if (asset = @assembly.load_path.find(path)) && asset.fresh?(digest) 20 | compiled_content = asset.compiled_content 21 | 22 | [ 23 | 200, 24 | { 25 | Rack::CONTENT_LENGTH => compiled_content.length.to_s, 26 | Rack::CONTENT_TYPE => asset.content_type.to_s, 27 | VARY => "Accept-Encoding", 28 | Rack::ETAG => "\"#{asset.digest}\"", 29 | Rack::CACHE_CONTROL => "public, max-age=31536000, immutable" 30 | }, 31 | method == "HEAD" ? [] : [ compiled_content ] 32 | ] 33 | else 34 | [ 404, { Rack::CONTENT_TYPE => "text/plain", Rack::CONTENT_LENGTH => "9" }, [ "Not found" ] ] 35 | end 36 | else 37 | @app.call(env) 38 | end 39 | end 40 | 41 | def inspect 42 | self.class.inspect 43 | end 44 | 45 | private 46 | def extract_path_and_digest(path) 47 | path = path.delete_prefix(@assembly.prefix) 48 | path = Rack::Utils.unescape(path) 49 | 50 | Propshaft::Asset.extract_path_and_digest(path) 51 | end 52 | 53 | if Gem::Version.new(Rack::RELEASE) < Gem::Version.new("3") 54 | VARY = "Vary" 55 | else 56 | VARY = "vary" 57 | end 58 | 59 | def execute_cache_sweeper_if_updated 60 | if @assembly.config.sweep_cache 61 | @assembly.load_path.cache_sweeper.execute_if_updated 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /benchmarks/trackrod.rb: -------------------------------------------------------------------------------- 1 | require "active_support/ordered_options" 2 | require 'fileutils' 3 | 4 | class Trackrod 5 | attr_accessor :root, :images, :css, :javascript 6 | 7 | def initialize(dir = nil) 8 | @root = dir || "#{Dir.getwd}/trackrod" 9 | @images = @root + "/images" 10 | @css = @root + "/stylesheets" 11 | @javascript = @root + "/javascript" 12 | end 13 | 14 | def build 15 | puts "Building Trackrod assets" 16 | 17 | create_dir(root) 18 | create_dir(images) 19 | create_dir(css) 20 | create_dir(javascript) 21 | 22 | create_images 23 | create_css 24 | create_javascript 25 | end 26 | 27 | def assets 28 | @assets ||= ActiveSupport::InheritableOptions.new( 29 | css: "stylesheets/application.css", 30 | js: "javascript/application.js", 31 | images: (small_images + large_images).map { "images/#{_1}" } 32 | ) 33 | end 34 | 35 | private 36 | def create_css 37 | File.open("#{css}/application.css", "a") do |file| 38 | small_images.each_with_index { |img, idx| file.write(background(img, idx)) } 39 | large_images.each_with_index { |img, idx| file.write(background(img, idx)) } 40 | end 41 | end 42 | 43 | def create_javascript 44 | end 45 | 46 | def create_images 47 | small_images.each { |img| FileUtils.touch "#{images}/#{img}" } 48 | large_images.each { |img| File.open("#{images}/#{img}", "a") { |file| file.write("a" * 2 ** 23) } unless File.exist?(img) } 49 | 50 | nil 51 | end 52 | 53 | def small_images 54 | @small_images ||= (1..5000).map { "s#{_1}.jpg" } 55 | end 56 | 57 | def large_images 58 | @large_images ||= (1..100).map { "l#{_1}.jpg" } 59 | end 60 | 61 | def background(img, idx) 62 | ".background_#{idx} {\n background: url('../images/#{img}') \n}\n\n" 63 | end 64 | 65 | def create_dir(path) 66 | Dir.mkdir(path) unless File.exist?(path) 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/propshaft/assembly_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "propshaft/assembly" 3 | require "active_support/ordered_options" 4 | 5 | class Propshaft::AssemblyTest < ActiveSupport::TestCase 6 | test "uses static resolver when manifest is present" do 7 | assembly = Propshaft::Assembly.new(ActiveSupport::OrderedOptions.new.tap { |config| 8 | config.output_path = Pathname.new("#{__dir__}/../fixtures/output") 9 | config.manifest_path = config.output_path.join(".manifest.json") 10 | config.prefix = "/assets" 11 | }) 12 | 13 | assert assembly.resolver.is_a?(Propshaft::Resolver::Static) 14 | end 15 | 16 | test "uses dynamic resolver when manifest is missing" do 17 | assembly = Propshaft::Assembly.new(ActiveSupport::OrderedOptions.new.tap { |config| 18 | config.output_path = Pathname.new("#{__dir__}/../fixtures/assets") 19 | config.manifest_path = config.output_path.join(".manifest.json") 20 | config.prefix = "/assets" 21 | }) 22 | 23 | assert assembly.resolver.is_a?(Propshaft::Resolver::Dynamic) 24 | end 25 | 26 | test "costly methods are memoized" do 27 | assembly = Propshaft::Assembly.new(ActiveSupport::OrderedOptions.new.tap { |config| 28 | config.output_path = Pathname.new("#{__dir__}/../fixtures/assets") 29 | config.manifest_path = config.output_path.join(".manifest.json") 30 | config.prefix = "/assets" 31 | }) 32 | 33 | assert_equal assembly.resolver.object_id, assembly.resolver.object_id 34 | assert_equal assembly.load_path.object_id, assembly.load_path.object_id 35 | end 36 | 37 | test "instantiates a valid processor" do 38 | assembly = Propshaft::Assembly.new(ActiveSupport::OrderedOptions.new.tap { |config| 39 | config.output_path = Pathname.new("#{__dir__}/../fixtures/assets") 40 | config.manifest_path = config.output_path.join(".manifest.json") 41 | config.prefix = "/assets" 42 | }) 43 | 44 | assert assembly.processor.is_a?(Propshaft::Processor) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/propshaft_integration_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class PropshaftIntegrationTest < ActionDispatch::IntegrationTest 4 | test "should be able to resolve real assets" do 5 | get sample_load_real_assets_url 6 | 7 | assert_response :success 8 | 9 | assert_select 'link[href="/assets/hello_world-4137140a.css"][data-custom-attribute="true"]' 10 | assert_select 'link[href="/assets/goodbye-b1dc9940.css"][data-custom-attribute="true"]' 11 | assert_select 'link[href="/assets/library-86a3b7a9.css"][data-custom-attribute="true"]' 12 | 13 | hello_css_link = css_select('link[href="/assets/hello_world-4137140a.css"][integrity]').first 14 | assert(hello_css_link) 15 | assert_equal "stylesheet", hello_css_link["rel"] 16 | assert_equal "sha384-ZSAt6UaTZ1OYvSB1fr2WXE8izMW4qnd17BZ1zaZ3TpAdIw3VEUmyupHd/k/cMCqM", hello_css_link["integrity"] 17 | 18 | hello_js_script = css_select('script[src="/assets/hello_world-888761f8.js"]').first 19 | assert(hello_js_script) 20 | assert_equal "sha384-BIr0kyMRq2sfytK/T0XlGjfav9ZZrWkSBC2yHVunCchnkpP83H28/UtHw+m9iNHO", hello_js_script["integrity"] 21 | end 22 | 23 | test "should prioritize app assets over engine assets" do 24 | get sample_load_real_assets_url 25 | 26 | assert_select 'script[src="/assets/actioncable-2e7de4f9.js"]' 27 | end 28 | 29 | test "should find app styles via glob" do 30 | get sample_load_real_assets_url 31 | 32 | assert_select 'link[href="/assets/hello_world-4137140a.css"][data-glob-attribute="true"]' 33 | assert_select 'link[href="/assets/goodbye-b1dc9940.css"][data-glob-attribute="true"]' 34 | assert_select('link[href="/assets/library-86a3b7a9.css"][data-glob-attribute="true"]', count: 0) 35 | end 36 | 37 | test "should raise an exception when resolving nonexistent assets" do 38 | exception = assert_raises ActionView::Template::Error do 39 | get sample_load_nonexistent_assets_url 40 | end 41 | assert_equal "The asset 'nonexistent.css' was not found in the load path.", exception.message 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/propshaft/assembly.rb: -------------------------------------------------------------------------------- 1 | require "propshaft/manifest" 2 | require "propshaft/load_path" 3 | require "propshaft/resolver/dynamic" 4 | require "propshaft/resolver/static" 5 | require "propshaft/server" 6 | require "propshaft/processor" 7 | require "propshaft/compilers" 8 | require "propshaft/compiler/css_asset_urls" 9 | require "propshaft/compiler/js_asset_urls" 10 | require "propshaft/compiler/source_mapping_urls" 11 | 12 | class Propshaft::Assembly 13 | attr_reader :config 14 | 15 | def initialize(config) 16 | @config = config 17 | end 18 | 19 | def load_path 20 | @load_path ||= Propshaft::LoadPath.new( 21 | config.paths, 22 | compilers: compilers, 23 | version: config.version, 24 | file_watcher: config.file_watcher, 25 | integrity_hash_algorithm: config.integrity_hash_algorithm 26 | ) 27 | end 28 | 29 | def resolver 30 | @resolver ||= if config.manifest_path.exist? 31 | Propshaft::Resolver::Static.new manifest_path: config.manifest_path, prefix: config.prefix 32 | else 33 | Propshaft::Resolver::Dynamic.new load_path: load_path, prefix: config.prefix 34 | end 35 | end 36 | 37 | def prefix 38 | @prefix ||= begin 39 | prefix = config.prefix || "/" 40 | prefix.end_with?("/") ? prefix : "#{prefix}/" 41 | end 42 | end 43 | 44 | def processor 45 | Propshaft::Processor.new \ 46 | load_path: load_path, output_path: config.output_path, compilers: compilers, manifest_path: config.manifest_path 47 | end 48 | 49 | def compilers 50 | @compilers ||= 51 | Propshaft::Compilers.new(self).tap do |compilers| 52 | Array(config.compilers).each do |(mime_type, klass)| 53 | compilers.register mime_type, klass 54 | end 55 | end 56 | end 57 | 58 | def reveal(path_type = :logical_path) 59 | path_type = path_type.presence_in(%i[ logical_path path ]) || raise(ArgumentError, "Unknown path_type: #{path_type}") 60 | 61 | load_path.assets.collect do |asset| 62 | asset.send(path_type) 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/propshaft/processor.rb: -------------------------------------------------------------------------------- 1 | require "propshaft/output_path" 2 | 3 | class Propshaft::Processor 4 | attr_reader :load_path, :output_path, :compilers, :manifest_path 5 | 6 | def initialize(load_path:, output_path:, compilers:, manifest_path:) 7 | @load_path, @output_path = load_path, output_path 8 | @manifest_path = manifest_path 9 | @compilers = compilers 10 | end 11 | 12 | def process 13 | ensure_output_path_exists 14 | write_manifest 15 | output_assets 16 | end 17 | 18 | def clobber 19 | FileUtils.rm_r(output_path) if File.exist?(output_path) 20 | end 21 | 22 | def clean(count) 23 | Propshaft::OutputPath.new(output_path, load_path.manifest).clean(count, 1.hour) 24 | end 25 | 26 | private 27 | def ensure_output_path_exists 28 | FileUtils.mkdir_p output_path 29 | end 30 | 31 | 32 | def write_manifest 33 | FileUtils.mkdir_p(File.dirname(manifest_path)) 34 | File.open(manifest_path, "wb+") do |manifest| 35 | manifest.write load_path.manifest.to_json 36 | end 37 | end 38 | 39 | 40 | def output_assets 41 | load_path.assets.each do |asset| 42 | unless output_path.join(asset.digested_path).exist? 43 | Propshaft.logger.info "Writing #{asset.digested_path}" 44 | FileUtils.mkdir_p output_path.join(asset.digested_path.parent) 45 | output_asset(asset) 46 | end 47 | end 48 | end 49 | 50 | def output_asset(asset) 51 | compile_asset(asset) || copy_asset(asset) 52 | end 53 | 54 | def compile_asset(asset) 55 | File.open(output_path.join(asset.digested_path), "w+") do |file| 56 | begin 57 | file.write asset.compiled_content 58 | rescue Encoding::UndefinedConversionError 59 | # FIXME: Not sure if there's a better way here? 60 | file.write asset.compiled_content.force_encoding("UTF-8") 61 | end 62 | end if compilers.compilable?(asset) 63 | end 64 | 65 | def copy_asset(asset) 66 | FileUtils.copy asset.path, output_path.join(asset.digested_path) 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable/disable caching. By default caching is disabled. 18 | # Run rails dev:cache to toggle caching. 19 | if Rails.root.join("tmp/caching-dev.txt").exist? 20 | config.action_controller.perform_caching = true 21 | config.action_controller.enable_fragment_cache_logging = true 22 | 23 | config.cache_store = :memory_store 24 | config.public_file_server.headers = { 25 | "Cache-Control" => "public, max-age=#{2.days.to_i}" 26 | } 27 | else 28 | config.action_controller.perform_caching = false 29 | 30 | config.cache_store = :null_store 31 | end 32 | 33 | # Print deprecation notices to the Rails logger. 34 | config.active_support.deprecation = :log 35 | 36 | # Raise exceptions for disallowed deprecations. 37 | config.active_support.disallowed_deprecation = :raise 38 | 39 | # Tell Active Support which deprecation messages to disallow. 40 | config.active_support.disallowed_deprecation_warnings = [] 41 | 42 | # Suppress logger output for asset requests. 43 | config.assets.quiet = true 44 | 45 | # Raises error for missing translations. 46 | # config.i18n.raise_on_missing_translations = true 47 | 48 | # Annotate rendered view with file names. 49 | # config.action_view.annotate_rendered_view_with_filenames = true 50 | 51 | # Uncomment if you wish to allow Action Cable access from any origin. 52 | # config.action_cable.disable_request_forgery_protection = true 53 | end 54 | -------------------------------------------------------------------------------- /test/propshaft/compiler/js_asset_urls_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "minitest/mock" 3 | require "propshaft/asset" 4 | require "propshaft/assembly" 5 | require "propshaft/compilers" 6 | 7 | require "propshaft/compiler/js_asset_urls" 8 | 9 | module Propshaft 10 | class Compiler 11 | class JsAssetUrlsTest < ActiveSupport::TestCase 12 | setup do 13 | @options = ActiveSupport::OrderedOptions.new.tap do |config| 14 | config.paths = [Pathname.new("#{__dir__}/../../fixtures/assets/vendor")] 15 | config.output_path = Pathname.new("#{__dir__}/../../fixtures/output") 16 | config.prefix = "/assets" 17 | end 18 | end 19 | 20 | test "the asset exists" do 21 | js_content = <<~JS 22 | export default class extends Controller { 23 | init() { 24 | this.img = RAILS_ASSET_URL("/foobar/source/file.svg"); 25 | } 26 | } 27 | JS 28 | 29 | compiled = compile_asset_with_content(js_content) 30 | 31 | assert_match(%r{this\.img = "/assets/foobar/source/file-[a-z0-9]{8}.svg"\;}, compiled) 32 | end 33 | 34 | test "the asset does not exist" do 35 | js_content = <<~JS 36 | export default class extends Controller { 37 | init() { 38 | this.img = RAILS_ASSET_URL("missing.svg"); 39 | } 40 | } 41 | JS 42 | 43 | compiled = compile_asset_with_content(js_content) 44 | 45 | assert_match(/this\.img = "missing.svg"\;/, compiled) 46 | end 47 | 48 | private 49 | 50 | def compile_asset_with_content(content) 51 | # This has one more set of .. than it would in the propshaft repo 52 | root_path = Pathname.new("#{__dir__}/../../fixtures/assets/vendor") 53 | logical_path = "foobar/source/test.js" 54 | 55 | assembly = Propshaft::Assembly.new(@options) 56 | assembly.compilers.register("text/javascript", Propshaft::Compiler::JsAssetUrls) 57 | 58 | asset = Propshaft::Asset.new(root_path.join(logical_path), logical_path: logical_path, load_path: assembly.load_path) 59 | asset.stub(:content, content) do 60 | assembly.compilers.compile(asset) 61 | end 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | # Turn false under Spring and add config.action_view.cache_template_loading = true 12 | config.cache_classes = true 13 | 14 | # Do not eager load code on boot. This avoids loading your whole application 15 | # just for the purpose of running a single test. If you are using a tool that 16 | # preloads Rails for running tests, you may have to set it to true. 17 | config.eager_load = false 18 | 19 | # Configure public file server for tests with Cache-Control for performance. 20 | config.public_file_server.enabled = true 21 | config.public_file_server.headers = { 22 | "Cache-Control" => "public, max-age=#{1.hour.to_i}" 23 | } 24 | 25 | # Show full error reports and disable caching. 26 | config.consider_all_requests_local = true 27 | config.action_controller.perform_caching = false 28 | config.cache_store = :null_store 29 | 30 | # Raise exceptions instead of rendering exception templates. 31 | if Rails.version < "7.1" 32 | config.action_dispatch.show_exceptions = false 33 | else 34 | # For Rails 7.0 and earlier, we set this to :none to avoid 35 | config.action_dispatch.show_exceptions = :none 36 | end 37 | 38 | # Disable request forgery protection in test environment. 39 | config.action_controller.allow_forgery_protection = false 40 | 41 | # Print deprecation notices to the stderr. 42 | config.active_support.deprecation = :stderr 43 | 44 | # Raise exceptions for disallowed deprecations. 45 | config.active_support.disallowed_deprecation = :raise 46 | 47 | # Tell Active Support which deprecation messages to disallow. 48 | config.active_support.disallowed_deprecation_warnings = [] 49 | 50 | # Raises error for missing translations. 51 | # config.i18n.raise_on_missing_translations = true 52 | 53 | # Annotate rendered view with file names. 54 | # config.action_view.annotate_rendered_view_with_filenames = true 55 | end 56 | -------------------------------------------------------------------------------- /lib/propshaft/asset.rb: -------------------------------------------------------------------------------- 1 | require "digest/sha1" 2 | require "digest/sha2" 3 | require "action_dispatch/http/mime_type" 4 | 5 | class Propshaft::Asset 6 | attr_reader :path, :logical_path, :load_path 7 | 8 | class << self 9 | def extract_path_and_digest(digested_path) 10 | digest = digested_path[/-([0-9a-zA-Z]{7,128})\.(?!digested)([^.]|.map)+\z/, 1] 11 | path = digest ? digested_path.sub("-#{digest}", "") : digested_path 12 | 13 | [path, digest] 14 | end 15 | end 16 | 17 | def initialize(path, logical_path:, load_path:) 18 | @path, @logical_path, @load_path = path, Pathname.new(logical_path), load_path 19 | end 20 | 21 | def compiled_content 22 | @compiled_content ||= load_path.compilers.compile(self) 23 | end 24 | 25 | def content(encoding: "ASCII-8BIT") 26 | File.read(path, encoding: encoding, mode: "rb") 27 | end 28 | 29 | def content_type 30 | Mime::Type.lookup_by_extension(logical_path.extname.from(1)) 31 | end 32 | 33 | def length 34 | content.size 35 | end 36 | 37 | def digest 38 | @digest ||= Digest::SHA1.hexdigest("#{content_with_compile_references}#{load_path.version}").first(8) 39 | end 40 | 41 | def integrity(hash_algorithm:) 42 | # Following the Subresource Integrity spec draft 43 | # https://w3c.github.io/webappsec-subresource-integrity/ 44 | # allowing only sha256, sha384, and sha512 45 | bitlen = case hash_algorithm 46 | when "sha256" 47 | 256 48 | when "sha384" 49 | 384 50 | when "sha512" 51 | 512 52 | else 53 | raise(StandardError.new("Subresource Integrity hash algorithm must be one of SHA2 family (sha256, sha384, sha512)")) 54 | end 55 | 56 | [hash_algorithm, Digest::SHA2.new(bitlen).base64digest(compiled_content)].join("-") 57 | end 58 | 59 | def digested_path 60 | if already_digested? 61 | logical_path 62 | else 63 | logical_path.sub(/\.(\w+(\.map)?)$/) { |ext| "-#{digest}#{ext}" } 64 | end 65 | end 66 | 67 | def fresh?(digest) 68 | self.digest == digest || already_digested? 69 | end 70 | 71 | def ==(other_asset) 72 | logical_path.hash == other_asset.logical_path.hash 73 | end 74 | 75 | private 76 | def content_with_compile_references 77 | content + load_path.find_referenced_by(self).collect(&:content).join 78 | end 79 | 80 | def already_digested? 81 | logical_path.to_s =~ /-([0-9a-zA-Z_-]{7,128})\.digested/ 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/propshaft/resolver/dynamic_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "propshaft/resolver/dynamic" 3 | 4 | class Propshaft::Resolver::DynamicTest < ActiveSupport::TestCase 5 | setup do 6 | @resolver = create_resolver 7 | end 8 | 9 | test "resolving present asset returns uri path" do 10 | assert_equal "/assets/one-f2e1ec14.txt", 11 | @resolver.resolve("one.txt") 12 | end 13 | 14 | test "reading static asset" do 15 | assert_equal "ASCII-8BIT", @resolver.read("one.txt").encoding.to_s 16 | assert_equal "One from first path", @resolver.read("one.txt") 17 | end 18 | 19 | test "reading static asset with encoding option" do 20 | assert_equal "UTF-8", @resolver.read("one.txt", encoding: "UTF-8").encoding.to_s 21 | assert_equal "One from first path", @resolver.read("one.txt", encoding: "UTF-8") 22 | end 23 | 24 | test "resolving missing asset returns nil" do 25 | assert_nil @resolver.resolve("nowhere.txt") 26 | end 27 | 28 | test "integrity for asset returns value for configured hash format" do 29 | resolver = create_resolver(integrity_hash_algorithm: "sha384") 30 | assert_equal "sha384-LdS8l2QTAF8bD8WPb8QSQv0skTWHhmcnS2XU5LBkVQneGzqIqnDRskQtJvi7ADMe", resolver.integrity("one.txt") 31 | end 32 | 33 | test "integrity for asset return the value for the compiled content instead of the source" do 34 | compilers = [["text/css", Propshaft::Compiler::CssAssetUrls ]] 35 | resolver = create_resolver(integrity_hash_algorithm: "sha384", compilers: compilers) 36 | assert_equal "sha384-jUiHGq2aPNACr4g68crM1I28TitXJKYhEgokcX6W5VYGwufEKQxfLpe4GakM84ex", resolver.integrity("another.css") 37 | end 38 | 39 | test "integrity for asset returns nil for no configured hash format" do 40 | assert_nil @resolver.integrity("one.txt") 41 | end 42 | 43 | test "integrity for missing asset returns nil" do 44 | resolver = create_resolver(integrity_hash_algorithm: "sha384") 45 | assert_nil resolver.integrity("nowhere.txt") 46 | end 47 | 48 | private 49 | def create_resolver(integrity_hash_algorithm: nil, compilers: []) 50 | assembly = Propshaft::Assembly.new(ActiveSupport::OrderedOptions.new.tap { |config| 51 | config.paths = [ 52 | Pathname.new("#{__dir__}/../../fixtures/assets/first_path"), 53 | ] 54 | config.compilers = compilers 55 | config.integrity_hash_algorithm = integrity_hash_algorithm 56 | }) 57 | 58 | Propshaft::Resolver::Dynamic.new(load_path: assembly.load_path, prefix: "/assets") 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/propshaft/processor_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "propshaft/load_path" 3 | require "propshaft/processor" 4 | 5 | class Propshaft::ProcessorTest < ActiveSupport::TestCase 6 | setup do 7 | @assembly = create_assembly 8 | end 9 | 10 | test "manifest is written" do 11 | processed do |processor| 12 | manifest = JSON.load_file(processor.output_path.join(".manifest.json")) 13 | manifest_entry = manifest["one.txt"] 14 | 15 | assert_equal "one-f2e1ec14.txt", manifest_entry["digested_path"] 16 | assert_nil manifest_entry["integrity"], "Integrity should not be present by default" 17 | end 18 | end 19 | 20 | test "integrity is written in the manifest when configured" do 21 | assembly = create_assembly do |config| 22 | config.integrity_hash_algorithm = "sha384" 23 | end 24 | 25 | processed(assembly) do |processor| 26 | manifest = JSON.load_file(processor.output_path.join(".manifest.json")) 27 | manifest_entry = manifest["one.txt"] 28 | 29 | assert_equal manifest_entry["digested_path"], "one-f2e1ec14.txt" 30 | assert_equal manifest_entry["integrity"], "sha384-LdS8l2QTAF8bD8WPb8QSQv0skTWHhmcnS2XU5LBkVQneGzqIqnDRskQtJvi7ADMe" 31 | end 32 | end 33 | 34 | test "assets are copied" do 35 | processed do |processor| 36 | digested_asset_name = "one-f2e1ec14.txt" 37 | assert processor.output_path.join(digested_asset_name).exist? 38 | 39 | nested_digested_asset_name = "nested/three-6c2b86a0.txt" 40 | assert processor.output_path.join(nested_digested_asset_name).exist? 41 | end 42 | end 43 | 44 | test "assets are clobbered" do 45 | processed do |processor| 46 | processor.clobber 47 | assert_not File.exist?(processor.output_path) 48 | FileUtils.mkdir_p processor.output_path 49 | end 50 | end 51 | 52 | private 53 | def create_assembly 54 | Propshaft::Assembly.new(ActiveSupport::OrderedOptions.new.tap { |config| 55 | config.output_path = Pathname.new("#{__dir__}/../fixtures/output") 56 | config.prefix = "/assets" 57 | config.paths = [ 58 | Pathname.new("#{__dir__}/../fixtures/assets/first_path"), 59 | Pathname.new("#{__dir__}/../fixtures/assets/second_path") 60 | ] 61 | yield config if block_given? 62 | }) 63 | end 64 | 65 | def processed(assembly = @assembly) 66 | Dir.mktmpdir do |output_path| 67 | output_path = Pathname.new(output_path) 68 | processor = Propshaft::Processor.new( 69 | load_path: assembly.load_path, output_path: output_path, 70 | compilers: assembly.compilers, manifest_path: output_path.join(".manifest.json") 71 | ) 72 | 73 | processor.process 74 | 75 | yield processor 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/propshaft/output_path_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "minitest/mock" 3 | require "propshaft/asset" 4 | require "propshaft/load_path" 5 | require "propshaft/output_path" 6 | 7 | class Propshaft::OutputPathTest < ActiveSupport::TestCase 8 | setup do 9 | @manifest = { 10 | ".manifest.json": ".manifest.json", 11 | "one.txt": "one-f2e1ec14.txt", 12 | "one.txt.map": "one-f2e1ec15.txt.map" 13 | }.stringify_keys 14 | @output_path = Propshaft::OutputPath.new(Pathname.new("#{__dir__}/../fixtures/output"), @manifest) 15 | end 16 | 17 | test "files" do 18 | files = @output_path.files 19 | 20 | file = files["one-f2e1ec14.txt"] 21 | assert_equal "one.txt", file[:logical_path] 22 | assert_equal "f2e1ec14", file[:digest] 23 | assert file[:mtime].is_a?(Time) 24 | end 25 | 26 | test "clean always keeps most current versions" do 27 | @output_path.clean(0, 0) 28 | assert @output_path.path.join(@manifest["one.txt"]) 29 | assert @output_path.path.join(@manifest[".manifest.json"]) 30 | end 31 | 32 | test "clean keeps versions of assets that no longer exist" do 33 | removed = output_asset("no-longer-in-manifest.txt", "current") 34 | @output_path.clean(1, 0) 35 | assert File.exist?(removed) 36 | ensure 37 | FileUtils.rm(removed) if File.exist?(removed) 38 | end 39 | 40 | test "clean keeps the correct number of versions" do 41 | old = output_asset("by_count.txt", "old", created_at: Time.now - 300) 42 | current = output_asset("by_count.txt", "current", created_at: Time.now - 180) 43 | 44 | @output_path.clean(1, 0) 45 | 46 | assert File.exist?(current) 47 | assert_not File.exist?(old) 48 | ensure 49 | FileUtils.rm(old) if File.exist?(old) 50 | FileUtils.rm(current) if File.exist?(current) 51 | end 52 | 53 | test "clean keeps the correct number of versions regardless of the file extension" do 54 | old = output_asset("by_count.txt.map", "old", created_at: Time.now - 300) 55 | current = output_asset("by_count.txt.map", "current", created_at: Time.now - 180) 56 | 57 | assert File.exist?(current) 58 | assert File.exist?(old) 59 | 60 | @output_path.clean(1, 0) 61 | 62 | assert File.exist?(current) 63 | assert_not File.exist?(old), "#{old} should not exist" 64 | ensure 65 | FileUtils.rm(old) if File.exist?(old) 66 | FileUtils.rm(current) if File.exist?(current) 67 | end 68 | 69 | test "clean keeps all versions under a certain age" do 70 | old = output_asset("by_age.txt", "old") 71 | current = output_asset("by_age.txt", "current") 72 | 73 | @output_path.clean(0, 3600) 74 | 75 | assert File.exist?(current) 76 | assert File.exist?(old) 77 | ensure 78 | FileUtils.rm(old) if File.exist?(old) 79 | FileUtils.rm(current) if File.exist?(current) 80 | end 81 | 82 | private 83 | def output_asset(filename, content, created_at: Time.now) 84 | load_path = Propshaft::LoadPath.new([], compilers: Propshaft::Compilers.new(nil)) 85 | asset = Propshaft::Asset.new(nil, logical_path: filename, load_path: load_path) 86 | asset.stub :content, content do 87 | output_path = @output_path.path.join(asset.digested_path) 88 | `touch -mt #{created_at.strftime('%y%m%d%H%M')} #{output_path}` 89 | output_path 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/propshaft/server_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "propshaft/assembly" 3 | require "propshaft/server" 4 | 5 | class Propshaft::ServerTest < ActiveSupport::TestCase 6 | include Rack::Test::Methods 7 | 8 | class RackApp 9 | attr_reader :calls 10 | 11 | def initialize 12 | @calls = [] 13 | end 14 | 15 | def call(env) 16 | @calls << env 17 | [200, {}, ["OK"]] 18 | end 19 | end 20 | 21 | setup do 22 | @assembly = Propshaft::Assembly.new(ActiveSupport::OrderedOptions.new.tap { |config| 23 | config.paths = [Pathname.new("#{__dir__}/../fixtures/assets/vendor"), Pathname.new("#{__dir__}/../fixtures/assets/first_path")] 24 | config.output_path = Pathname.new("#{__dir__}../fixtures/output") 25 | config.prefix = "/assets" 26 | }) 27 | 28 | @rack_app = RackApp.new 29 | @assembly.compilers.register "text/css", Propshaft::Compiler::CssAssetUrls 30 | @server = Propshaft::Server.new(@rack_app, @assembly) 31 | end 32 | 33 | test "forward requests not under prefix" do 34 | get "/test" 35 | assert_not_empty @rack_app.calls 36 | end 37 | 38 | test "forward requests that aren't GET or HEAD" do 39 | asset = @assembly.load_path.find("foobar/source/test.css") 40 | post "/assets/#{asset.digested_path}" 41 | assert_not_empty @rack_app.calls 42 | end 43 | 44 | test "serve a compiled file" do 45 | asset = @assembly.load_path.find("foobar/source/test.css") 46 | get "/assets/#{asset.digested_path}" 47 | 48 | assert_equal 200, last_response.status 49 | assert_equal last_response.body.bytesize.to_s, last_response.headers['content-length'] 50 | assert_equal "text/css", last_response.headers['content-type'] 51 | assert_equal "Accept-Encoding", last_response.headers['vary'] 52 | assert_equal "\"#{asset.digest}\"", last_response.headers['etag'] 53 | assert_equal "public, max-age=31536000, immutable", last_response.headers['cache-control'] 54 | assert_equal ".hero { background: url(\"/assets/foobar/source/file-3e6a1297.jpg\") }\n", 55 | last_response.body 56 | end 57 | 58 | test "serve a predigested file" do 59 | asset = @assembly.load_path.find("file-already-abcdefVWXYZ0123456789_-.digested.css") 60 | get "/assets/#{asset.digested_path}" 61 | assert_equal 200, last_response.status 62 | end 63 | 64 | test "serve a sourcemap" do 65 | asset = @assembly.load_path.find("file-is-a-sourcemap.js.map") 66 | get "/assets/#{asset.digested_path}" 67 | assert_equal 200, last_response.status 68 | end 69 | 70 | test "not found" do 71 | get "/assets/not-found.js" 72 | 73 | assert_equal 404, last_response.status 74 | assert_equal "9", last_response.headers['content-length'] 75 | assert_equal "text/plain", last_response.headers['content-type'] 76 | assert_equal "Not found", last_response.body 77 | assert_not last_response.headers.key?('cache-control') 78 | assert_not last_response.headers.key?('etag') 79 | assert_not last_response.headers.key?('accept-encoding') 80 | end 81 | 82 | test "not found if digest does not match" do 83 | asset = @assembly.load_path.find("foobar/source/test.css") 84 | get "/assets/#{asset.logical_path}" 85 | assert_equal 404, last_response.status 86 | end 87 | 88 | private 89 | def app 90 | @app ||= Rack::Lint.new(@server) 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/propshaft/load_path_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "propshaft/load_path" 3 | 4 | class Propshaft::LoadPathTest < ActiveSupport::TestCase 5 | setup do 6 | @load_path = Propshaft::LoadPath.new [ 7 | Pathname.new("#{__dir__}/../fixtures/assets/first_path"), 8 | Pathname.new("#{__dir__}/../fixtures/assets/second_path").to_s 9 | ], compilers: Propshaft::Compilers.new(nil) 10 | end 11 | 12 | test "find asset that only appears once in the paths" do 13 | assert_equal "Two from second path", @load_path.find("two.txt").content 14 | end 15 | 16 | test "find asset from first path if it appears twice in the paths" do 17 | assert_equal "One from first path", @load_path.find("one.txt").content 18 | end 19 | 20 | test "find nested asset" do 21 | assert_equal "Three from first path", @load_path.find("nested/three.txt").content 22 | end 23 | 24 | test "assets" do 25 | assert_includes @load_path.assets, find_asset("one.txt") 26 | end 27 | 28 | test "assets dont include dot files" do 29 | assert_not_includes @load_path.assets, find_asset(".stuff") 30 | end 31 | 32 | test "manifest" do 33 | @load_path.manifest.tap do |manifest| 34 | assert_equal "one-f2e1ec14.txt", manifest["one.txt"].digested_path.to_s 35 | assert_equal "nested/three-6c2b86a0.txt", manifest["nested/three.txt"].digested_path.to_s 36 | end 37 | end 38 | 39 | test "manifest with version" do 40 | @load_path = Propshaft::LoadPath.new(@load_path.paths, version: "1", compilers: Propshaft::Compilers.new(nil)) 41 | @load_path.manifest.tap do |manifest| 42 | assert_equal "one-c9373b68.txt", manifest["one.txt"].digested_path.to_s 43 | assert_equal "nested/three-a41a5d38.txt", manifest["nested/three.txt"].digested_path.to_s 44 | end 45 | end 46 | 47 | test "missing load path directory" do 48 | assert_nil Propshaft::LoadPath.new(Pathname.new("#{__dir__}/../fixtures/assets/nowhere"), compilers: Propshaft::Compilers.new(nil)).find("missing") 49 | end 50 | 51 | test "deduplicate paths" do 52 | load_path = Propshaft::LoadPath.new [ 53 | "app/javascript", 54 | "app/javascript/packs", 55 | "app/assets/stylesheets", 56 | "app/assets/images", 57 | "app/assets" 58 | ], compilers: Propshaft::Compilers.new(nil) 59 | 60 | paths = load_path.paths 61 | assert_equal 2, paths.count 62 | assert_equal Pathname.new("app/javascript"), paths.first 63 | assert_equal Pathname.new("app/assets"), paths.last 64 | end 65 | 66 | test "asset paths by type" do 67 | assert_equal \ 68 | ["another.css", "dependent/a.css", "dependent/b.css", "dependent/c.css", "file-already-abcdefVWXYZ0123456789_-.digested.css", "file-already-abcdefVWXYZ0123456789_-.digested.debug.css", "file-not.digested.css"], 69 | @load_path.asset_paths_by_type("css") 70 | end 71 | 72 | test "asset paths by glob" do 73 | assert_equal \ 74 | ["dependent/a.css", "dependent/b.css", "dependent/c.css"], 75 | @load_path.asset_paths_by_glob("**/dependent/*.css") 76 | end 77 | 78 | private 79 | def find_asset(logical_path) 80 | root_path = Pathname.new("#{__dir__}/../fixtures/assets/first_path") 81 | load_path = Propshaft::LoadPath.new([ root_path ], compilers: Propshaft::Compilers.new(nil)) 82 | Propshaft::Asset.new(root_path.join(logical_path), logical_path: logical_path, load_path: load_path) 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /test/propshaft/resolver/static_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "minitest/mock" 3 | require "propshaft/resolver/static" 4 | 5 | class Propshaft::Resolver::StaticTest < ActiveSupport::TestCase 6 | setup do 7 | @resolver = Propshaft::Resolver::Static.new( 8 | manifest_path: Pathname.new("#{__dir__}/../../fixtures/output/.manifest.json"), 9 | prefix: "/assets" 10 | ) 11 | end 12 | 13 | test "resolving present asset returns uri path" do 14 | assert_equal \ 15 | "/assets/one-f2e1ec14.txt", 16 | @resolver.resolve("one.txt") 17 | end 18 | 19 | test "reading static asset" do 20 | assert_equal "ASCII-8BIT", @resolver.read("one.txt").encoding.to_s 21 | assert_equal "One from first path", @resolver.read("one.txt") 22 | end 23 | 24 | test "reading static asset with encoding option" do 25 | assert_equal "UTF-8", @resolver.read("one.txt", encoding: "UTF-8").encoding.to_s 26 | assert_equal "One from first path", @resolver.read("one.txt", encoding: "UTF-8") 27 | end 28 | 29 | test "resolving missing asset returns nil" do 30 | assert_nil @resolver.resolve("nowhere.txt") 31 | end 32 | 33 | test "integrity for asset returns nil for simple manifest" do 34 | assert_nil @resolver.integrity("one.txt") 35 | end 36 | 37 | test "integrity for missing asset returns nil" do 38 | assert_nil @resolver.integrity("nowhere.txt") 39 | end 40 | 41 | test "resolver requests json optimizer gems to keep parsed manifest keys as strings" do 42 | stub = Proc.new do |_, opts| 43 | assert_equal false, opts[:symbolize_names] 44 | {} 45 | end 46 | 47 | JSON.stub :parse, stub do 48 | @resolver.resolve("one.txt") 49 | end 50 | end 51 | 52 | class Propshaft::Resolver::StaticTest::WithExtensibleManifest < ActiveSupport::TestCase 53 | setup do 54 | @resolver = Propshaft::Resolver::Static.new( 55 | manifest_path: Pathname.new("#{__dir__}/../../fixtures/new_manifest_format/.manifest.json"), 56 | prefix: "/assets" 57 | ) 58 | end 59 | 60 | test "resolving present asset returns uri path" do 61 | assert_equal \ 62 | "/assets/one-f2e1ec14.txt", 63 | @resolver.resolve("one.txt") 64 | end 65 | 66 | test "reading static asset" do 67 | assert_equal "ASCII-8BIT", @resolver.read("one.txt").encoding.to_s 68 | assert_equal "One from first path", @resolver.read("one.txt") 69 | end 70 | 71 | test "reading static asset with encoding option" do 72 | assert_equal "UTF-8", @resolver.read("one.txt", encoding: "UTF-8").encoding.to_s 73 | assert_equal "One from first path", @resolver.read("one.txt", encoding: "UTF-8") 74 | end 75 | 76 | test "resolving missing asset returns nil" do 77 | assert_nil @resolver.resolve("nowhere.txt") 78 | end 79 | 80 | test "integrity for asset returns value from extensible manifest" do 81 | assert_equal "sha384-LdS8l2QTAF8bD8WPb8QSQv0skTWHhmcnS2XU5LBkVQneGzqIqnDRskQtJvi7ADMe", @resolver.integrity("one.txt") 82 | end 83 | 84 | test "integrity for missing asset returns nil" do 85 | assert_nil @resolver.integrity("nowhere.txt") 86 | end 87 | 88 | test "resolver requests json optimizer gems to keep parsed manifest keys as strings" do 89 | stub = Proc.new do |_, opts| 90 | assert_equal false, opts[:symbolize_names] 91 | {} 92 | end 93 | 94 | JSON.stub :parse, stub do 95 | @resolver.resolve("one.txt") 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/propshaft/railtie.rb: -------------------------------------------------------------------------------- 1 | require "rails" 2 | require "active_support/ordered_options" 3 | require "propshaft/quiet_assets" 4 | 5 | module Propshaft 6 | class Railtie < ::Rails::Railtie 7 | config.assets = ActiveSupport::OrderedOptions.new 8 | config.assets.paths = [] 9 | config.assets.excluded_paths = [] 10 | config.assets.version = "1" 11 | config.assets.prefix = "/assets" 12 | config.assets.quiet = false 13 | config.assets.compilers = [ 14 | [ "text/css", Propshaft::Compiler::CssAssetUrls ], 15 | [ "text/css", Propshaft::Compiler::SourceMappingUrls ], 16 | [ "text/javascript", Propshaft::Compiler::JsAssetUrls ], 17 | [ "text/javascript", Propshaft::Compiler::SourceMappingUrls ], 18 | ] 19 | config.assets.sweep_cache = Rails.env.development? 20 | config.assets.server = Rails.env.development? || Rails.env.test? 21 | config.assets.relative_url_root = nil 22 | 23 | # Register propshaft initializer to copy the assets path in all the Rails Engines. 24 | # This makes possible for us to keep all `assets` config in this Railtie, but still 25 | # allow engines to automatically register their own paths. 26 | Rails::Engine.initializer "propshaft.append_assets_path", group: :all do |app| 27 | app.config.assets.paths.unshift(*paths["vendor/assets"].existent_directories) 28 | app.config.assets.paths.unshift(*paths["lib/assets"].existent_directories) 29 | app.config.assets.paths.unshift(*paths["app/assets"].existent_directories) 30 | 31 | app.config.assets.paths = app.config.assets.paths.without(Array(app.config.assets.excluded_paths).collect(&:to_s)) 32 | end 33 | 34 | config.after_initialize do |app| 35 | # Prioritize assets from within the application over assets of the same path from engines/gems. 36 | config.assets.paths.sort_by!.with_index { |path, i| [path.to_s.start_with?(Rails.root.to_s) ? 0 : 1, i] } 37 | 38 | config.assets.file_watcher ||= app.config.file_watcher 39 | 40 | config.assets.relative_url_root ||= app.config.relative_url_root 41 | config.assets.output_path ||= 42 | Pathname.new(File.join(app.config.paths["public"].first, app.config.assets.prefix)) 43 | config.assets.manifest_path ||= config.assets.output_path.join(".manifest.json") 44 | 45 | ActiveSupport.on_load(:action_view) do 46 | include Propshaft::Helper 47 | end 48 | 49 | if config.assets.sweep_cache 50 | ActiveSupport.on_load(:action_controller_base) do 51 | before_action { Rails.application.assets.load_path.cache_sweeper.execute_if_updated } 52 | end 53 | end 54 | end 55 | 56 | initializer "propshaft.logger" do 57 | Propshaft.logger = config.assets.logger || Rails.logger 58 | end 59 | 60 | initializer :quiet_assets do |app| 61 | if app.config.assets.quiet 62 | app.middleware.insert_before ::Rails::Rack::Logger, Propshaft::QuietAssets 63 | end 64 | end 65 | 66 | initializer "propshaft.assets_middleware", group: :all do |app| 67 | app.assets = Propshaft::Assembly.new(app.config.assets) 68 | if config.assets.server 69 | app.middleware.insert_before ::ActionDispatch::Executor, Propshaft::Server, app.assets 70 | end 71 | end 72 | 73 | rake_tasks do 74 | load "propshaft/railties/assets.rake" 75 | end 76 | 77 | # Compatibility shiming (need to provide log warnings when used) 78 | config.assets.precompile = [] 79 | config.assets.debug = nil 80 | config.assets.compile = nil 81 | config.assets.css_compressor = nil 82 | config.assets.js_compressor = nil 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/propshaft/load_path.rb: -------------------------------------------------------------------------------- 1 | require "propshaft/manifest" 2 | require "propshaft/asset" 3 | 4 | class Propshaft::LoadPath 5 | class NullFileWatcher # :nodoc: 6 | def initialize(paths, files_to_watch, &block) 7 | @block = block 8 | end 9 | 10 | def execute_if_updated 11 | @block.call 12 | end 13 | end 14 | 15 | attr_reader :paths, :compilers, :version, :integrity_hash_algorithm 16 | 17 | def initialize(paths = [], compilers:, version: nil, file_watcher: nil, integrity_hash_algorithm: nil) 18 | @paths, @compilers, @version, @integrity_hash_algorithm = dedup(paths), compilers, version, integrity_hash_algorithm 19 | @file_watcher = file_watcher || NullFileWatcher 20 | end 21 | 22 | def find(asset_name) 23 | assets_by_path[asset_name] 24 | end 25 | 26 | def find_referenced_by(asset) 27 | compilers.referenced_by(asset).delete(self) 28 | end 29 | 30 | def assets 31 | assets_by_path.values 32 | end 33 | 34 | def asset_paths_by_type(content_type) 35 | (@cached_asset_paths_by_type ||= Hash.new)[content_type] ||= 36 | extract_logical_paths_from(assets.select { |a| a.content_type == Mime::EXTENSION_LOOKUP[content_type] }) 37 | end 38 | 39 | def asset_paths_by_glob(glob) 40 | (@cached_asset_paths_by_glob ||= Hash.new)[glob] ||= 41 | extract_logical_paths_from(assets.select { |a| a.path.fnmatch?(glob) }) 42 | end 43 | 44 | def manifest 45 | Propshaft::Manifest.new(integrity_hash_algorithm: integrity_hash_algorithm).tap do |manifest| 46 | assets.each { |asset| manifest.push_asset(asset) } 47 | end 48 | end 49 | 50 | # Returns a file watcher object configured to clear the cache of the load_path 51 | # when the directories passed during its initialization have changes. This is used in development 52 | # and test to ensure the map caches are reset when javascript files are changed. 53 | def cache_sweeper 54 | @cache_sweeper ||= begin 55 | exts_to_watch = Mime::EXTENSION_LOOKUP.map(&:first) 56 | files_to_watch = Array(paths).collect { |dir| [ dir.to_s, exts_to_watch ] }.to_h 57 | mutex = Mutex.new 58 | 59 | @file_watcher.new([], files_to_watch) do 60 | mutex.synchronize do 61 | clear_cache 62 | seed_cache 63 | end 64 | end 65 | end 66 | end 67 | 68 | private 69 | def assets_by_path 70 | @cached_assets_by_path ||= Hash.new.tap do |mapped| 71 | paths.each do |path| 72 | without_dotfiles(all_files_from_tree(path)).each do |file| 73 | logical_path = file.relative_path_from(path) 74 | mapped[logical_path.to_s] ||= Propshaft::Asset.new(file, logical_path: logical_path, load_path: self) 75 | end if path.exist? 76 | end 77 | end 78 | end 79 | 80 | def all_files_from_tree(path) 81 | path.children.flat_map { |child| child.directory? ? all_files_from_tree(child) : child } 82 | end 83 | 84 | def extract_logical_paths_from(assets) 85 | assets.collect { |asset| asset.logical_path.to_s }.sort 86 | end 87 | 88 | def without_dotfiles(files) 89 | files.reject { |file| file.basename.to_s.starts_with?(".") } 90 | end 91 | 92 | def clear_cache 93 | @cached_assets_by_path = nil 94 | @cached_asset_paths_by_type = nil 95 | @cached_asset_paths_by_glob = nil 96 | end 97 | 98 | def seed_cache 99 | assets_by_path 100 | end 101 | 102 | def dedup(paths) 103 | paths = Array(paths).map { |path| Pathname.new(path) } 104 | deduped = [].tap do |deduped| 105 | paths.sort.each { |path| deduped << path if deduped.blank? || !path.to_s.start_with?(deduped.last.to_s) } 106 | end 107 | 108 | paths & deduped 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /test/propshaft/compiler/source_mapping_urls_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "minitest/mock" 3 | require "propshaft/asset" 4 | require "propshaft/assembly" 5 | require "propshaft/compilers" 6 | 7 | class Propshaft::Compiler::SourceMappingUrlsTest < ActiveSupport::TestCase 8 | setup do 9 | @options = ActiveSupport::OrderedOptions.new.tap { |config| 10 | config.paths = [ Pathname.new("#{__dir__}/../../fixtures/assets/mapped") ] 11 | config.output_path = Pathname.new("#{__dir__}/../../fixtures/output") 12 | config.prefix = "/assets" 13 | } 14 | end 15 | 16 | test "matching source map" do 17 | assert_match %r{//# sourceMappingURL=/assets/source-[a-z0-9]{8}\.js.map}, 18 | compile_asset(find_asset("source.js", fixture_path: "mapped")) 19 | assert_match %r{/\*# sourceMappingURL=/assets/source-[a-z0-9]{8}\.css.map}, 20 | compile_asset(find_asset("source.css", fixture_path: "mapped")) 21 | end 22 | 23 | test "matching nested source map" do 24 | assert_match %r{//# sourceMappingURL=/assets/nested/another-source-[a-z0-9]{8}\.js.map}, 25 | compile_asset(find_asset("nested/another-source.js", fixture_path: "mapped")) 26 | end 27 | 28 | test "missing source map" do 29 | assert_no_match %r{sourceMappingURL}, 30 | compile_asset(find_asset("sourceless.js", fixture_path: "mapped")) 31 | assert_no_match %r{sourceMappingURL}, 32 | compile_asset(find_asset("sourceless.css", fixture_path: "mapped")) 33 | end 34 | 35 | test "sourceMappingURL removal due to missing map does not damage /* ... */ comments" do 36 | assert_match %r{\A#{Regexp.escape ".failure { color: red; }\n/* */\n"}\Z}, 37 | compile_asset(find_asset("sourceless.css", fixture_path: "mapped")) 38 | end 39 | 40 | test "sourceMappingURL not at the beginning of the line, but at end of file, is processed" do 41 | assert_match %r{//# sourceMappingURL=/assets/sourceMappingURL-not-at-start-[a-z0-9]{8}\.js.map}, 42 | compile_asset(find_asset("sourceMappingURL-not-at-start.js", fixture_path: "mapped")) 43 | assert_match %r{/\*# sourceMappingURL=/assets/sourceMappingURL-not-at-start-[a-z0-9]{8}\.css.map \*/}, 44 | compile_asset(find_asset("sourceMappingURL-not-at-start.css", fixture_path: "mapped")) 45 | end 46 | 47 | test "sourceMappingURL not at end of file should be left alone" do 48 | assert_match %r{sourceMappingURL=sourceMappingURL-not-at-end.css.map}, 49 | compile_asset(find_asset("sourceMappingURL-not-at-end.css", fixture_path: "mapped")) 50 | end 51 | test "sourceMappingURL outside of a comment should be left alone" do 52 | assert_match %r{sourceMappingURL=sourceMappingURL-outside-comment.css.map}, 53 | compile_asset(find_asset("sourceMappingURL-outside-comment.css", fixture_path: "mapped")) 54 | end 55 | 56 | test "sourceMapURL is already prefixed with url_prefix" do 57 | assert_match %r{//# sourceMappingURL=/assets/sourceMappingURL-already-prefixed-[a-z0-9]{8}\.js\.map}, 58 | compile_asset(find_asset("sourceMappingURL-already-prefixed.js", fixture_path: "mapped")) 59 | assert_match %r{//# sourceMappingURL=/assets/nested/sourceMappingURL-already-prefixed-nested-[a-z0-9]{8}\.js\.map}, 60 | compile_asset(find_asset("nested/sourceMappingURL-already-prefixed-nested.js", fixture_path: "mapped")) 61 | end 62 | 63 | test "sourceMapURL is already prefixed with an incorrect url_prefix" do 64 | refute_match %r{//# sourceMappingURL=thisisinvalidassets/sourceMappingURL-already-prefixed-invalid.js-[a-z0-9]{8}\.map}, 65 | compile_asset(find_asset("sourceMappingURL-already-prefixed-invalid.js", fixture_path: "mapped")) 66 | end 67 | 68 | test "relative url root" do 69 | @options.relative_url_root = "/url-root" 70 | 71 | assert_match %r{//# sourceMappingURL=/url-root/assets/source-[a-z0-9]{8}\.js.map}, 72 | compile_asset(find_asset("source.js", fixture_path: "mapped")) 73 | end 74 | 75 | private 76 | def compile_asset(asset) 77 | 78 | assembly = Propshaft::Assembly.new(@options) 79 | assembly.compilers.register "text/javascript", Propshaft::Compiler::SourceMappingUrls 80 | assembly.compilers.register "text/css", Propshaft::Compiler::SourceMappingUrls 81 | 82 | assembly.compilers.compile(asset) 83 | end 84 | end 85 | 86 | # //# sourceMappingURL=/assets/sourceMappingURL-already-prefixed.js-[a-z0-9]{40}.map 87 | # //# sourceMappingURL=/assets/sourceMappingURL-already-prefixed.js-da39a3ee.map 88 | -------------------------------------------------------------------------------- /test/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.cache_classes = true 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | config.action_controller.perform_caching = true 18 | 19 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 20 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 21 | # config.require_master_key = true 22 | 23 | # Disable serving static files from the `/public` folder by default since 24 | # Apache or NGINX already handles this. 25 | config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? 26 | 27 | # Compress CSS using a preprocessor. 28 | # config.assets.css_compressor = :sass 29 | 30 | # Do not fallback to assets pipeline if a precompiled asset is missed. 31 | config.assets.compile = false 32 | 33 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 34 | # config.asset_host = "http://assets.example.com" 35 | 36 | # Specifies the header that your server uses for sending files. 37 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache 38 | # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX 39 | 40 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 41 | # config.force_ssl = true 42 | 43 | # Include generic and useful information about system operation, but avoid logging too much 44 | # information to avoid inadvertent exposure of personally identifiable information (PII). 45 | config.log_level = :info 46 | 47 | # Prepend all log lines with the following tags. 48 | config.log_tags = [ :request_id ] 49 | 50 | # Use a different cache store in production. 51 | # config.cache_store = :mem_cache_store 52 | 53 | # Use a real queuing backend for Active Job (and separate queues per environment). 54 | # config.active_job.queue_adapter = :resque 55 | # config.active_job.queue_name_prefix = "dummy_production" 56 | 57 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 58 | # the I18n.default_locale when a translation cannot be found). 59 | config.i18n.fallbacks = true 60 | 61 | # Don't log any deprecations. 62 | config.active_support.report_deprecations = false 63 | 64 | # Use default logging formatter so that PID and timestamp are not suppressed. 65 | config.log_formatter = ::Logger::Formatter.new 66 | 67 | # Use a different logger for distributed setups. 68 | # require "syslog/logger" 69 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new "app-name") 70 | 71 | if ENV["RAILS_LOG_TO_STDOUT"].present? 72 | logger = ActiveSupport::Logger.new(STDOUT) 73 | logger.formatter = config.log_formatter 74 | config.logger = ActiveSupport::TaggedLogging.new(logger) 75 | end 76 | 77 | # Inserts middleware to perform automatic connection switching. 78 | # The `database_selector` hash is used to pass options to the DatabaseSelector 79 | # middleware. The `delay` is used to determine how long to wait after a write 80 | # to send a subsequent read to the primary. 81 | # 82 | # The `database_resolver` class is used by the middleware to determine which 83 | # database is appropriate to use based on the time delay. 84 | # 85 | # The `database_resolver_context` class is used by the middleware to set 86 | # timestamps for the last write to the primary. The resolver uses the context 87 | # class timestamps to determine how long to wait before reading from the 88 | # replica. 89 | # 90 | # By default Rails will store a last write timestamp in the session. The 91 | # DatabaseSelector middleware is designed as such you can define your own 92 | # strategy for connection switching and pass that into the middleware through 93 | # these configuration options. 94 | # config.active_record.database_selector = { delay: 2.seconds } 95 | # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver 96 | # config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session 97 | end 98 | -------------------------------------------------------------------------------- /test/propshaft/asset_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "propshaft/asset" 3 | require "propshaft/load_path" 4 | 5 | class Propshaft::AssetTest < ActiveSupport::TestCase 6 | test "content" do 7 | assert_equal "ASCII-8BIT", find_asset("one.txt").content.encoding.to_s 8 | assert_equal "One from first path", find_asset("one.txt").content 9 | end 10 | 11 | test "content with encoding" do 12 | assert_equal "UTF-8", find_asset("one.txt").content(encoding: "UTF-8").encoding.to_s 13 | assert_equal "One from first path", find_asset("one.txt").content(encoding: "UTF-8") 14 | end 15 | 16 | test "content type" do 17 | assert_equal "text/plain", find_asset("one.txt").content_type.to_s 18 | assert_equal "text/javascript", find_asset("again.js").content_type.to_s 19 | assert_equal "text/css", find_asset("another.css").content_type.to_s 20 | end 21 | 22 | test "length" do 23 | assert_equal 19, find_asset("one.txt").length 24 | end 25 | 26 | test "digest" do 27 | assert_equal "f2e1ec14", find_asset("one.txt").digest 28 | end 29 | 30 | test "fresh" do 31 | assert find_asset("one.txt").fresh?("f2e1ec14") 32 | assert_not find_asset("one.txt").fresh?("e206c34f") 33 | 34 | assert find_asset("file-already-abcdefVWXYZ0123456789_-.digested.css").fresh?(nil) 35 | end 36 | 37 | test "digested path" do 38 | assert_equal "one-f2e1ec14.txt", 39 | find_asset("one.txt").digested_path.to_s 40 | 41 | assert_equal "file-already-abcdefVWXYZ0123456789_-.digested.css", 42 | find_asset("file-already-abcdefVWXYZ0123456789_-.digested.css").digested_path.to_s 43 | 44 | assert_equal "file-already-abcdefVWXYZ0123456789_-.digested.debug.css", 45 | find_asset("file-already-abcdefVWXYZ0123456789_-.digested.debug.css").digested_path.to_s 46 | 47 | assert_equal "file-not.digested-e206c34f.css", 48 | find_asset("file-not.digested.css").digested_path.to_s 49 | 50 | assert_equal "file-is-a-sourcemap-da39a3ee.js.map", 51 | find_asset("file-is-a-sourcemap.js.map").digested_path.to_s 52 | end 53 | 54 | test "integrity" do 55 | assert_equal "sha256-+C/K/0dPvIdSC8rl/NDS8zqPp08R0VH+hKMM4D8tNJs=", 56 | find_asset("one.txt").integrity(hash_algorithm: "sha256").to_s 57 | 58 | assert_equal "sha384-LdS8l2QTAF8bD8WPb8QSQv0skTWHhmcnS2XU5LBkVQneGzqIqnDRskQtJvi7ADMe", 59 | find_asset("one.txt").integrity(hash_algorithm: "sha384").to_s 60 | 61 | assert_equal "sha512-wzPP7om24750PjHXRlgiDOhILPd4V2AbLRxomBudQaTDI1eYZkM5j8pSH/ylSSUxiGqXR3F6lgVCbsmXkqKrEg==", 62 | find_asset("one.txt").integrity(hash_algorithm: "sha512").to_s 63 | 64 | exception = assert_raises StandardError do 65 | find_asset("one.txt").integrity(hash_algorithm: "md5") 66 | end 67 | assert_equal "Subresource Integrity hash algorithm must be one of SHA2 family (sha256, sha384, sha512)", exception.message 68 | end 69 | 70 | test "value object equality" do 71 | assert_equal find_asset("one.txt"), find_asset("one.txt") 72 | end 73 | 74 | test "compiled content for non-compilable asset" do 75 | asset = find_asset("one.txt") 76 | assert_equal "One from first path", asset.compiled_content 77 | assert_equal asset.content, asset.compiled_content 78 | end 79 | 80 | test "compiled content for css asset with url transformation" do 81 | asset = find_asset("another.css") 82 | compiled = asset.compiled_content 83 | 84 | assert_match(/url\("\/archive-[a-f0-9]+\.svg"\)/, compiled) 85 | assert_not_equal asset.content, asset.compiled_content 86 | end 87 | 88 | 89 | test "costly methods are memoized" do 90 | asset = find_asset("one.txt") 91 | assert_equal asset.digest.object_id, asset.digest.object_id 92 | end 93 | 94 | test "digest depends on first level of compiler dependency" do 95 | open_asset_with_reset("dependent/b.css") do |asset_file| 96 | digest_before_dependency_change = find_asset("dependent/a.css").digest 97 | 98 | asset_file.write "changes!" 99 | asset_file.flush 100 | 101 | digest_after_dependency_change = find_asset("dependent/a.css").digest 102 | 103 | assert_not_equal digest_before_dependency_change, digest_after_dependency_change 104 | end 105 | end 106 | 107 | test "digest depends on second level of compiler dependency" do 108 | open_asset_with_reset("dependent/c.css") do |asset_file| 109 | digest_before_dependency_change = find_asset("dependent/a.css").digest 110 | 111 | asset_file.write "changes!" 112 | asset_file.flush 113 | 114 | digest_after_dependency_change = find_asset("dependent/a.css").digest 115 | 116 | assert_not_equal digest_before_dependency_change, digest_after_dependency_change 117 | end 118 | end 119 | 120 | private 121 | def find_asset(logical_path) 122 | root_path = Pathname.new("#{__dir__}/../fixtures/assets/first_path") 123 | path = root_path.join(logical_path) 124 | 125 | assembly = Propshaft::Assembly.new(ActiveSupport::OrderedOptions.new.tap { |config| 126 | config.paths = [ root_path ] 127 | config.compilers = [[ "text/css", Propshaft::Compiler::CssAssetUrls ]] 128 | }) 129 | 130 | Propshaft::Asset.new(path, logical_path: logical_path, load_path: assembly.load_path) 131 | end 132 | 133 | def open_asset_with_reset(logical_path) 134 | dependency_path = Pathname.new("#{__dir__}/../fixtures/assets/first_path/#{logical_path}") 135 | existing_dependency_content = File.read(dependency_path) 136 | 137 | File.open(dependency_path, "a") { |f| yield f } 138 | ensure 139 | File.write(dependency_path, existing_dependency_content) 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | propshaft (1.3.1) 5 | actionpack (>= 7.0.0) 6 | activesupport (>= 7.0.0) 7 | rack 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | actioncable (8.0.2) 13 | actionpack (= 8.0.2) 14 | activesupport (= 8.0.2) 15 | nio4r (~> 2.0) 16 | websocket-driver (>= 0.6.1) 17 | zeitwerk (~> 2.6) 18 | actionmailbox (8.0.2) 19 | actionpack (= 8.0.2) 20 | activejob (= 8.0.2) 21 | activerecord (= 8.0.2) 22 | activestorage (= 8.0.2) 23 | activesupport (= 8.0.2) 24 | mail (>= 2.8.0) 25 | actionmailer (8.0.2) 26 | actionpack (= 8.0.2) 27 | actionview (= 8.0.2) 28 | activejob (= 8.0.2) 29 | activesupport (= 8.0.2) 30 | mail (>= 2.8.0) 31 | rails-dom-testing (~> 2.2) 32 | actionpack (8.0.2) 33 | actionview (= 8.0.2) 34 | activesupport (= 8.0.2) 35 | nokogiri (>= 1.8.5) 36 | rack (>= 2.2.4) 37 | rack-session (>= 1.0.1) 38 | rack-test (>= 0.6.3) 39 | rails-dom-testing (~> 2.2) 40 | rails-html-sanitizer (~> 1.6) 41 | useragent (~> 0.16) 42 | actiontext (8.0.2) 43 | actionpack (= 8.0.2) 44 | activerecord (= 8.0.2) 45 | activestorage (= 8.0.2) 46 | activesupport (= 8.0.2) 47 | globalid (>= 0.6.0) 48 | nokogiri (>= 1.8.5) 49 | actionview (8.0.2) 50 | activesupport (= 8.0.2) 51 | builder (~> 3.1) 52 | erubi (~> 1.11) 53 | rails-dom-testing (~> 2.2) 54 | rails-html-sanitizer (~> 1.6) 55 | activejob (8.0.2) 56 | activesupport (= 8.0.2) 57 | globalid (>= 0.3.6) 58 | activemodel (8.0.2) 59 | activesupport (= 8.0.2) 60 | activerecord (8.0.2) 61 | activemodel (= 8.0.2) 62 | activesupport (= 8.0.2) 63 | timeout (>= 0.4.0) 64 | activestorage (8.0.2) 65 | actionpack (= 8.0.2) 66 | activejob (= 8.0.2) 67 | activerecord (= 8.0.2) 68 | activesupport (= 8.0.2) 69 | marcel (~> 1.0) 70 | activesupport (8.0.2) 71 | base64 72 | benchmark (>= 0.3) 73 | bigdecimal 74 | concurrent-ruby (~> 1.0, >= 1.3.1) 75 | connection_pool (>= 2.2.5) 76 | drb 77 | i18n (>= 1.6, < 2) 78 | logger (>= 1.4.2) 79 | minitest (>= 5.1) 80 | securerandom (>= 0.3) 81 | tzinfo (~> 2.0, >= 2.0.5) 82 | uri (>= 0.13.1) 83 | base64 (0.3.0) 84 | benchmark (0.4.1) 85 | bigdecimal (3.2.2) 86 | builder (3.3.0) 87 | concurrent-ruby (1.3.5) 88 | connection_pool (2.5.3) 89 | crass (1.0.6) 90 | date (3.4.1) 91 | debug (1.11.0) 92 | irb (~> 1.10) 93 | reline (>= 0.3.8) 94 | drb (2.2.3) 95 | erb (5.0.1) 96 | erubi (1.13.1) 97 | globalid (1.2.1) 98 | activesupport (>= 6.1) 99 | i18n (1.14.7) 100 | concurrent-ruby (~> 1.0) 101 | io-console (0.8.0) 102 | irb (1.15.2) 103 | pp (>= 0.6.0) 104 | rdoc (>= 4.0.0) 105 | reline (>= 0.4.2) 106 | logger (1.7.0) 107 | loofah (2.24.1) 108 | crass (~> 1.0.2) 109 | nokogiri (>= 1.12.0) 110 | mail (2.8.1) 111 | mini_mime (>= 0.1.1) 112 | net-imap 113 | net-pop 114 | net-smtp 115 | marcel (1.0.4) 116 | mini_mime (1.1.5) 117 | mini_portile2 (2.8.9) 118 | minitest (5.25.5) 119 | net-imap (0.5.9) 120 | date 121 | net-protocol 122 | net-pop (0.1.2) 123 | net-protocol 124 | net-protocol (0.2.2) 125 | timeout 126 | net-smtp (0.5.1) 127 | net-protocol 128 | nio4r (2.7.4) 129 | nokogiri (1.18.8) 130 | mini_portile2 (~> 2.8.2) 131 | racc (~> 1.4) 132 | nokogiri (1.18.8-arm64-darwin) 133 | racc (~> 1.4) 134 | nokogiri (1.18.8-x86_64-darwin) 135 | racc (~> 1.4) 136 | nokogiri (1.18.8-x86_64-linux-gnu) 137 | racc (~> 1.4) 138 | pp (0.6.2) 139 | prettyprint 140 | prettyprint (0.2.0) 141 | psych (5.2.6) 142 | date 143 | stringio 144 | racc (1.8.1) 145 | rack (3.2.0) 146 | rack-session (2.1.1) 147 | base64 (>= 0.1.0) 148 | rack (>= 3.0.0) 149 | rack-test (2.2.0) 150 | rack (>= 1.3) 151 | rackup (2.2.1) 152 | rack (>= 3) 153 | rails (8.0.2) 154 | actioncable (= 8.0.2) 155 | actionmailbox (= 8.0.2) 156 | actionmailer (= 8.0.2) 157 | actionpack (= 8.0.2) 158 | actiontext (= 8.0.2) 159 | actionview (= 8.0.2) 160 | activejob (= 8.0.2) 161 | activemodel (= 8.0.2) 162 | activerecord (= 8.0.2) 163 | activestorage (= 8.0.2) 164 | activesupport (= 8.0.2) 165 | bundler (>= 1.15.0) 166 | railties (= 8.0.2) 167 | rails-dom-testing (2.3.0) 168 | activesupport (>= 5.0.0) 169 | minitest 170 | nokogiri (>= 1.6) 171 | rails-html-sanitizer (1.6.2) 172 | loofah (~> 2.21) 173 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 174 | railties (8.0.2) 175 | actionpack (= 8.0.2) 176 | activesupport (= 8.0.2) 177 | irb (~> 1.13) 178 | rackup (>= 1.0.0) 179 | rake (>= 12.2) 180 | thor (~> 1.0, >= 1.2.2) 181 | zeitwerk (~> 2.6) 182 | rake (13.3.0) 183 | rdoc (6.14.2) 184 | erb 185 | psych (>= 4.0.0) 186 | reline (0.6.1) 187 | io-console (~> 0.5) 188 | securerandom (0.4.1) 189 | stringio (3.1.7) 190 | thor (1.3.2) 191 | timeout (0.4.3) 192 | tzinfo (2.0.6) 193 | concurrent-ruby (~> 1.0) 194 | uri (1.0.3) 195 | useragent (0.16.11) 196 | websocket-driver (0.8.0) 197 | base64 198 | websocket-extensions (>= 0.1.0) 199 | websocket-extensions (0.1.5) 200 | zeitwerk (2.7.3) 201 | 202 | PLATFORMS 203 | arm64-darwin-20 204 | ruby 205 | x86_64-darwin-20 206 | x86_64-linux 207 | 208 | DEPENDENCIES 209 | debug 210 | propshaft! 211 | rails (>= 7.0.1) 212 | rake 213 | 214 | BUNDLED WITH 215 | 2.7.1 216 | -------------------------------------------------------------------------------- /lib/propshaft/manifest.rb: -------------------------------------------------------------------------------- 1 | module Propshaft 2 | # Manages the manifest file that maps logical asset paths to their digested counterparts. 3 | # 4 | # The manifest is used to track assets that have been processed and digested, storing 5 | # their logical paths, digested paths, and optional integrity hashes. 6 | class Manifest 7 | # Represents a single entry in the asset manifest. 8 | # 9 | # Each entry contains information about an asset including its logical path 10 | # (the original path), digested path (the path with content hash), and 11 | # optional integrity hash for security verification. 12 | class ManifestEntry 13 | attr_reader :logical_path, :digested_path, :integrity 14 | 15 | # Creates a new manifest entry. 16 | # 17 | # ==== Parameters 18 | # 19 | # * +logical_path+ - The logical path of the asset 20 | # * +digested_path+ - The digested path of the asset 21 | # * +integrity+ - The integrity hash of the asset (optional) 22 | def initialize(logical_path:, digested_path:, integrity:) # :nodoc: 23 | @logical_path = logical_path 24 | @digested_path = digested_path 25 | @integrity = integrity 26 | end 27 | 28 | # Converts the manifest entry to a hash representation. 29 | # 30 | # Returns a hash containing the +digested_path+ and +integrity+ keys. 31 | def to_h 32 | { digested_path: digested_path, integrity: integrity} 33 | end 34 | end 35 | 36 | class << self 37 | # Creates a new Manifest instance from a manifest file. 38 | # 39 | # Reads and parses a manifest file, supporting both the current format 40 | # (with +digested_path+ and +integrity+ keys) and the legacy format 41 | # (simple string values for backwards compatibility). 42 | # 43 | # ==== Parameters 44 | # 45 | # * +manifest_path+ - The path to the manifest file 46 | # 47 | # ==== Returns 48 | # 49 | # A new manifest instance populated with entries from the file. 50 | def from_path(manifest_path) 51 | manifest = Manifest.new 52 | 53 | serialized_manifest = JSON.parse(manifest_path.read, symbolize_names: false) 54 | 55 | serialized_manifest.each_pair do |key, value| 56 | # Compatibility mode to be able to 57 | # read the old "simple manifest" format 58 | digested_path, integrity = if value.is_a?(String) 59 | [value, nil] 60 | else 61 | [value["digested_path"], value["integrity"]] 62 | end 63 | 64 | entry = ManifestEntry.new( 65 | logical_path: key, digested_path: digested_path, integrity: integrity 66 | ) 67 | 68 | manifest.push(entry) 69 | end 70 | 71 | manifest 72 | end 73 | end 74 | 75 | # Creates a new Manifest instance. 76 | # 77 | # ==== Parameters 78 | # 79 | # * +integrity_hash_algorithm+ - The algorithm to use for generating 80 | # integrity hashes (e.g., 'sha256', 'sha384', 'sha512'). If +nil+, integrity hashes 81 | # will not be generated. 82 | def initialize(integrity_hash_algorithm: nil) 83 | @integrity_hash_algorithm = integrity_hash_algorithm 84 | @entries = {} 85 | end 86 | 87 | # Adds an asset to the manifest. 88 | # 89 | # Creates a manifest entry from the given asset and adds it to the manifest. 90 | # The entry will include the asset's logical path, digested path, and optionally 91 | # an integrity hash if an integrity hash algorithm is configured. 92 | # 93 | # ==== Parameters 94 | # 95 | # * +asset+ - The asset to add to the manifest 96 | # 97 | # ==== Returns 98 | # 99 | # The manifest entry that was added. 100 | def push_asset(asset) 101 | entry = ManifestEntry.new( 102 | logical_path: asset.logical_path.to_s, 103 | digested_path: asset.digested_path.to_s, 104 | integrity: integrity_hash_algorithm && asset.integrity(hash_algorithm: integrity_hash_algorithm) 105 | ) 106 | 107 | push(entry) 108 | end 109 | 110 | # Adds a manifest entry to the manifest. 111 | # 112 | # ==== Parameters 113 | # 114 | # * +entry+ - The manifest entry to add 115 | # 116 | # ==== Returns 117 | # 118 | # The entry that was added. 119 | def push(entry) 120 | @entries[entry.logical_path] = entry 121 | end 122 | alias_method :<<, :push 123 | 124 | # Retrieves a manifest entry by its logical path. 125 | # 126 | # ==== Parameters 127 | # 128 | # * +logical_path+ - The logical path of the asset to retrieve 129 | # 130 | # ==== Returns 131 | # 132 | # The manifest entry, or +nil+ if not found. 133 | def [](logical_path) 134 | @entries[logical_path] 135 | end 136 | 137 | # Removes a manifest entry by its logical path. 138 | # 139 | # ==== Parameters 140 | # 141 | # * +logical_path+ - The logical path of the asset to remove 142 | # 143 | # ==== Returns 144 | # 145 | # The removed manifest entry, or +nil+ if not found. 146 | def delete(logical_path) 147 | @entries.delete(logical_path) 148 | end 149 | 150 | # Converts the manifest to JSON format. 151 | # 152 | # The JSON representation maps logical paths to hash representations of 153 | # manifest entries, containing +digested_path+ and +integrity+ information. 154 | # 155 | # ==== Returns 156 | # 157 | # The JSON representation of the manifest. 158 | def to_json 159 | @entries.transform_values do |manifest_entry| 160 | manifest_entry.to_h 161 | end.to_json 162 | end 163 | 164 | # Transforms the values of all manifest entries using the given block. 165 | # 166 | # This method is useful for applying transformations to all manifest entries 167 | # while preserving the logical path keys. 168 | # 169 | # ==== Parameters 170 | # 171 | # * +block+ - A block that will receive each manifest entry 172 | # 173 | # ==== Returns 174 | # 175 | # A new hash with the same keys but transformed values. 176 | def transform_values(&block) 177 | @entries.transform_values(&block) 178 | end 179 | 180 | private 181 | attr_reader :integrity_hash_algorithm 182 | end 183 | end 184 | -------------------------------------------------------------------------------- /lib/propshaft/helper.rb: -------------------------------------------------------------------------------- 1 | module Propshaft 2 | # Helper module that provides asset path resolution and integrity support for Rails applications. 3 | # 4 | # This module extends Rails' built-in asset helpers with additional functionality: 5 | # - Subresource Integrity (SRI) support for enhanced security 6 | # - Bulk stylesheet inclusion with :all and :app options 7 | # - Asset path resolution with proper error handling 8 | # 9 | # == Subresource Integrity (SRI) Support 10 | # 11 | # SRI helps protect against malicious modifications of assets by ensuring that 12 | # resources fetched from CDNs or other sources haven't been tampered with. 13 | # 14 | # SRI is automatically enabled in secure contexts (HTTPS or local development) 15 | # when the 'integrity' option is set to true: 16 | # 17 | # <%= stylesheet_link_tag "application", integrity: true %> 18 | # <%= javascript_include_tag "application", integrity: true %> 19 | # 20 | # This will generate integrity hashes and include them in the HTML: 21 | # 22 | # 24 | # 26 | # 27 | # == Bulk Stylesheet Inclusion 28 | # 29 | # The stylesheet_link_tag helper supports special symbols for bulk inclusion: 30 | # - :all - includes all CSS files found in the load path 31 | # - :app - includes only CSS files from app/assets/**/*.css 32 | # 33 | # <%= stylesheet_link_tag :all %> # All stylesheets 34 | # <%= stylesheet_link_tag :app %> # Only app stylesheets 35 | module Helper 36 | # Computes the Subresource Integrity (SRI) hash for the given asset path. 37 | # 38 | # This method generates a cryptographic hash of the asset content that can be used 39 | # to verify the integrity of the resource when it's loaded by the browser. 40 | # 41 | # asset_integrity("application.css") 42 | # # => "sha256-xyz789abcdef..." 43 | def asset_integrity(path, options = {}) 44 | path = _path_with_extname(path, options) 45 | Rails.application.assets.resolver.integrity(path) 46 | end 47 | 48 | # Resolves the full path for an asset, raising an error if not found. 49 | def compute_asset_path(path, options = {}) 50 | Rails.application.assets.resolver.resolve(path) || raise(MissingAssetError.new(path)) 51 | end 52 | 53 | # Enhanced +stylesheet_link_tag+ with integrity support and bulk inclusion options. 54 | # 55 | # In addition to the standard Rails functionality, this method supports: 56 | # * Automatic SRI (Subresource Integrity) hash generation in secure contexts 57 | # * Add an option to call +stylesheet_link_tag+ with +:all+ to include every css 58 | # file found on the load path or +:app+ to include css files found in 59 | # Rails.root("app/assets/**/*.css"), which will exclude lib/ and plugins. 60 | # 61 | # ==== Options 62 | # 63 | # * :integrity - Enable SRI hash generation 64 | # 65 | # ==== Examples 66 | # 67 | # stylesheet_link_tag "application", integrity: true 68 | # # => 70 | # 71 | # stylesheet_link_tag :all # All stylesheets in load path 72 | # stylesheet_link_tag :app # Only app/assets stylesheets 73 | def stylesheet_link_tag(*sources) 74 | options = sources.extract_options! 75 | 76 | case sources.first 77 | when :all 78 | sources = all_stylesheets_paths 79 | when :app 80 | sources = app_stylesheets_paths 81 | end 82 | 83 | _build_asset_tags(sources, options, :stylesheet) { |source, opts| super(source, opts) } 84 | end 85 | 86 | # Enhanced +javascript_include_tag+ with automatic SRI (Subresource Integrity) support. 87 | # 88 | # This method extends Rails' built-in +javascript_include_tag+ to automatically 89 | # generate and include integrity hashes when running in secure contexts. 90 | # 91 | # ==== Options 92 | # 93 | # * :integrity - Enable SRI hash generation 94 | # 95 | # ==== Examples 96 | # 97 | # javascript_include_tag "application", integrity: true 98 | # # => 100 | def javascript_include_tag(*sources) 101 | options = sources.extract_options! 102 | 103 | _build_asset_tags(sources, options, :javascript) { |source, opts| super(source, opts) } 104 | end 105 | 106 | # Returns a sorted and unique array of logical paths for all stylesheets in the load path. 107 | def all_stylesheets_paths 108 | Rails.application.assets.load_path.asset_paths_by_type("css") 109 | end 110 | 111 | # Returns a sorted and unique array of logical paths for all stylesheets in app/assets/**/*.css. 112 | def app_stylesheets_paths 113 | Rails.application.assets.load_path.asset_paths_by_glob("#{Rails.root.join("app/assets")}/**/*.css") 114 | end 115 | 116 | private 117 | # Core method that builds asset tags with optional integrity support. 118 | # 119 | # This method handles the common logic for both +stylesheet_link_tag+ and 120 | # +javascript_include_tag+, including SRI hash generation and HTML tag creation. 121 | def _build_asset_tags(sources, options, asset_type) 122 | options = options.stringify_keys 123 | integrity = _compute_integrity?(options) 124 | 125 | sources.map { |source| 126 | opts = integrity ? options.merge!('integrity' => asset_integrity(source, type: asset_type)) : options 127 | yield(source, opts) 128 | }.join("\n").html_safe 129 | end 130 | 131 | # Determines whether integrity hashes should be computed for assets. 132 | # 133 | # Integrity is only computed in secure contexts (HTTPS or local development) 134 | # and when explicitly requested via the +integrity+ option. 135 | def _compute_integrity?(options) 136 | if _secure_subresource_integrity_context? 137 | case options['integrity'] 138 | when nil, false, true 139 | options.delete('integrity') == true 140 | end 141 | else 142 | options.delete 'integrity' 143 | false 144 | end 145 | end 146 | 147 | # Checks if the current context is secure enough for Subresource Integrity. 148 | # 149 | # SRI is only beneficial in secure contexts. Returns true when: 150 | # * The request is made over HTTPS (SSL), OR 151 | # * The request is local (development environment) 152 | def _secure_subresource_integrity_context? 153 | respond_to?(:request) && self.request && (self.request.local? || self.request.ssl?) 154 | end 155 | 156 | # Ensures the asset path includes the appropriate file extension. 157 | def _path_with_extname(path, options) 158 | "#{path}#{compute_asset_extname(path, options)}" 159 | end 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /test/propshaft/compiler/css_asset_urls_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "minitest/mock" 3 | require "propshaft/asset" 4 | require "propshaft/assembly" 5 | require "propshaft/compilers" 6 | 7 | class Propshaft::Compiler::CssAssetUrlsTest < ActiveSupport::TestCase 8 | setup do 9 | @options = ActiveSupport::OrderedOptions.new.tap { |config| 10 | config.paths = [ Pathname.new("#{__dir__}/../../fixtures/assets/vendor") ] 11 | config.output_path = Pathname.new("#{__dir__}/../../fixtures/output") 12 | config.prefix = "/assets" 13 | } 14 | end 15 | 16 | test "basic" do 17 | compiled = compile_asset_with_content(%({ background: url(file.jpg); })) 18 | assert_match(/{ background: url\("\/assets\/foobar\/source\/file-[a-z0-9]{8}.jpg"\); }/, compiled) 19 | end 20 | 21 | test "blank spaces around name" do 22 | compiled = compile_asset_with_content(%({ background: url( file.jpg ); })) 23 | assert_match(/{ background: url\("\/assets\/foobar\/source\/file-[a-z0-9]{8}.jpg"\); }/, compiled) 24 | end 25 | 26 | test "quotes around name" do 27 | compiled = compile_asset_with_content(%({ background: url("file.jpg"); })) 28 | assert_match(/{ background: url\("\/assets\/foobar\/source\/file-[a-z0-9]{8}.jpg"\); }/, compiled) 29 | end 30 | 31 | test "single quotes around name" do 32 | compiled = compile_asset_with_content(%({ background: url('file.jpg'); })) 33 | assert_match(/{ background: url\("\/assets\/foobar\/source\/file-[a-z0-9]{8}.jpg"\); }/, compiled) 34 | end 35 | 36 | test "root directory" do 37 | compiled = compile_asset_with_content(%({ background: url('/file.jpg'); })) 38 | assert_match(/{ background: url\("\/assets\/file-[a-z0-9]{8}.jpg"\); }/, compiled) 39 | end 40 | 41 | test "same directory" do 42 | compiled = compile_asset_with_content(%({ background: url('./file.jpg'); })) 43 | assert_match(/{ background: url\("\/assets\/foobar\/source\/file-[a-z0-9]{8}.jpg"\); }/, compiled) 44 | end 45 | 46 | test "subdirectory" do 47 | compiled = compile_asset_with_content(%({ background: url('./images/file.jpg'); })) 48 | assert_match(/{ background: url\("\/assets\/foobar\/source\/images\/file-[a-z0-9]{8}.jpg"\); }/, compiled) 49 | end 50 | 51 | test "parent directory" do 52 | compiled = compile_asset_with_content(%({ background: url('../file.jpg'); })) 53 | assert_match(/{ background: url\("\/assets\/foobar\/file-[a-z0-9]{8}.jpg"\); }/, compiled) 54 | end 55 | 56 | test "grandparent directory" do 57 | compiled = compile_asset_with_content(%({ background: url('../../file.jpg'); })) 58 | assert_match(/{ background: url\("\/assets\/file-[a-z0-9]{8}.jpg"\); }/, compiled) 59 | end 60 | 61 | test "sibling directory" do 62 | compiled = compile_asset_with_content(%({ background: url('../sibling/file.jpg'); })) 63 | assert_match(/{ background: url\("\/assets\/foobar\/sibling\/file-[a-z0-9]{8}.jpg"\); }/, compiled) 64 | end 65 | 66 | test "mixed" do 67 | compiled = compile_asset_with_content(%({ mask-image: image(url(file.jpg), skyblue, linear-gradient(rgba(0, 0, 0, 1.0), transparent)); })) 68 | assert_match(/{ mask-image: image\(url\("\/assets\/foobar\/source\/file-[a-z0-9]{8}.jpg"\), skyblue, linear-gradient\(rgba\(0, 0, 0, 1.0\), transparent\)\); }/, compiled) 69 | end 70 | 71 | test "multiple" do 72 | compiled = compile_asset_with_content(%({ content: url(file.svg) url(file.svg); })) 73 | assert_match(/{ content: url\("\/assets\/foobar\/source\/file-[a-z0-9]{8}.svg"\) url\("\/assets\/foobar\/source\/file-[a-z0-9]{8}.svg"\); }/, compiled) 74 | end 75 | 76 | test "url" do 77 | compiled = compile_asset_with_content(%({ background: url('https://rubyonrails.org/images/rails-logo.svg'); })) 78 | assert_match "{ background: url('https://rubyonrails.org/images/rails-logo.svg'); }", compiled 79 | 80 | compiled = compile_asset_with_content(%({ background: url(http-diagram.jpg); })) 81 | assert_match(/{ background: url\("\/assets\/foobar\/source\/http-diagram-[a-z0-9]{8}.jpg"\); }/, compiled) 82 | end 83 | 84 | test "relative protocol url" do 85 | compiled = compile_asset_with_content(%({ background: url('//rubyonrails.org/images/rails-logo.svg'); })) 86 | assert_match "{ background: url('//rubyonrails.org/images/rails-logo.svg'); }", compiled 87 | end 88 | 89 | test "data" do 90 | compiled = compile_asset_with_content(%({ background: url(data:image/png;base64,iRxVB0); })) 91 | assert_match "{ background: url(data:image/png;base64,iRxVB0); }", compiled 92 | 93 | compiled = compile_asset_with_content(%({ background: url(database.jpg); })) 94 | assert_match(/{ background: url\("\/assets\/foobar\/source\/database-[a-z0-9]{8}.jpg"\); }/, compiled) 95 | end 96 | 97 | test "anchor" do 98 | compiled = compile_asset_with_content(%({ background: url(#IDofSVGpath); })) 99 | assert_match "{ background: url(#IDofSVGpath); }", compiled 100 | end 101 | 102 | test "fingerprint" do 103 | compiled = compile_asset_with_content(%({ background: url('/file.jpg?30af91bf14e37666a085fb8a161ff36d'); })) 104 | assert_match(/{ background: url\("\/assets\/file-[a-z0-9]{8}.jpg\?30af91bf14e37666a085fb8a161ff36d"\); }/, compiled) 105 | end 106 | 107 | test "svg anchor" do 108 | compiled = compile_asset_with_content(%({ content: url(file.svg#rails); })) 109 | assert_match(/{ content: url\("\/assets\/foobar\/source\/file-[a-z0-9]{8}.svg#rails"\); }/, compiled) 110 | end 111 | 112 | test "svg mask encoded anchor" do 113 | compiled = compile_asset_with_content(%({ background: url("data:image/svg+xml;charset=utf-8,%3Csvg mask='url(%23MyMask)'%3E%3C/svg%3E"); })) 114 | assert_match "{ background: url(\"data:image/svg+xml;charset=utf-8,%3Csvg mask='url(%23MyMask)'%3E%3C/svg%3E\"); }", compiled 115 | end 116 | 117 | test "non greedy anchors" do 118 | compiled = compile_asset_with_content(%({ content: url(file.svg#demo) url(file.svg#demo); })) 119 | assert_match(/{ content: url\("\/assets\/foobar\/source\/file-[a-z0-9]{8}.svg#demo"\) url\("\/assets\/foobar\/source\/file-[a-z0-9]{8}.svg#demo"\); }/, compiled) 120 | end 121 | 122 | test "missing asset" do 123 | compiled = compile_asset_with_content(%({ background: url("file-not-found.jpg"); })) 124 | assert_match(/{ background: url\("file-not-found.jpg"\); }/, compiled) 125 | end 126 | 127 | test "relative url root" do 128 | @options.relative_url_root = "/url-root" 129 | 130 | compiled = compile_asset_with_content(%({ background: url(file.jpg); })) 131 | assert_match(/{ background: url\("\/url-root\/assets\/foobar\/source\/file-[a-z0-9]{8}.jpg"\); }/, compiled) 132 | end 133 | 134 | private 135 | def compile_asset_with_content(content) 136 | root_path = Pathname.new("#{__dir__}/../../fixtures/assets/vendor") 137 | logical_path = "foobar/source/test.css" 138 | 139 | assembly = Propshaft::Assembly.new(@options) 140 | assembly.compilers.register "text/css", Propshaft::Compiler::CssAssetUrls 141 | 142 | asset = Propshaft::Asset.new(root_path.join(logical_path), logical_path: logical_path, load_path: assembly.load_path) 143 | asset.stub :content, content do 144 | assembly.compilers.compile(asset) 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Propshaft 2 | 3 | Propshaft is an asset pipeline library for Rails. It's built for an era where bundling assets to save on HTTP connections is no longer urgent, where JavaScript and CSS are either compiled by dedicated Node.js bundlers or served directly to the browsers, and where increases in bandwidth have made the need for minification less pressing. These factors allow for a dramatically simpler and faster asset pipeline compared to previous options, like [Sprockets](https://github.com/rails/sprockets-rails). 4 | 5 | So that's what Propshaft doesn't do. Here's what it does provide: 6 | 7 | 1. **Configurable load path**: You can register directories from multiple places in your app and gems, and reference assets from all of these paths as though they were one. 8 | 1. **Digest stamping**: All assets in the load path will be copied (or compiled) in a precompilation step for production that also stamps all of them with a digest hash, so you can use long-expiry cache headers for better performance. The digested assets can be referred to through their logical path because the processing leaves a manifest file that provides a way to translate. 9 | 1. **Development server**: There's no need to precompile the assets in development. You can refer to them via the same asset_path helpers and they'll be served by a development server. 10 | 1. **Basic compilers**: Propshaft was explicitly not designed to provide full transpiler capabilities. You can get that better elsewhere. But it does offer a simple input->output compiler setup that by default is used to translate `url(asset)` function calls in CSS to `url(digested-asset)` instead and source mapping comments likewise. 11 | 12 | 13 | ## Installation 14 | 15 | With Rails 8, Propshaft is the default asset pipeline for new applications. With Rails 7, you can start a new application with propshaft using `rails new myapp -a propshaft`. For existing applications, check the [upgrade guide](https://github.com/rails/propshaft/blob/main/UPGRADING.md) which contains step-by-step instructions. 16 | 17 | ## Usage 18 | 19 | Propshaft makes all the assets from all the paths it's been configured with through `config.assets.paths` available for serving and will copy all of them into `public/assets` when precompiling. This is unlike Sprockets, which did not copy over assets that hadn't been explicitly included in one of the bundled assets. 20 | 21 | You can however exempt directories that have been added through the `config.assets.excluded_paths`. This is useful if you're for example using `app/assets/stylesheets` exclusively as a set of inputs to a compiler like Dart Sass for Rails, and you don't want these input files to be part of the load path. (Remember you need to add full paths, like `Rails.root.join("app/assets/stylesheets")`). 22 | 23 | These assets can be referenced through their logical path using the normal helpers like `asset_path`, `image_tag`, `javascript_include_tag`, and all the other asset helper tags. These logical references are automatically converted into digest-aware paths in production when `assets:precompile` has been run (through a JSON mapping file found in `public/assets/.manifest.json`). 24 | 25 | ## Referencing digested assets in CSS and JavaScript 26 | 27 | Propshaft will automatically convert asset references in CSS to use the digested file names. So `background: url("/bg/pattern.svg")` is converted to `background: url("/assets/bg/pattern-2169cbef.svg")` before the stylesheet is served. 28 | 29 | For JavaScript, you'll have to manually trigger this transformation by using the `RAILS_ASSET_URL` pseudo-method. It's used like this: 30 | 31 | ```javascript 32 | export default class extends Controller { 33 | init() { 34 | this.img = RAILS_ASSET_URL("/icons/trash.svg") 35 | } 36 | } 37 | ``` 38 | 39 | That'll turn into: 40 | 41 | ```javascript 42 | export default class extends Controller { 43 | init() { 44 | this.img = "/assets/icons/trash-54g9cbef.svg" 45 | } 46 | } 47 | ``` 48 | 49 | ## Bypassing the digest step 50 | 51 | If you need to put multiple files that refer to each other through Propshaft, like a JavaScript file and its source map, you have to digest these files in advance to retain stable file names. Propshaft looks for the specific pattern of `-[digest].digested.js` as the postfix to any asset file as an indication that the file has already been digested. 52 | 53 | ## Subresource Integrity (SRI) 54 | 55 | Propshaft supports Subresource Integrity (SRI) to help protect against malicious modifications of assets. SRI allows browsers to verify that resources fetched from CDNs or other sources haven't been tampered with by checking cryptographic hashes. 56 | 57 | ### Enabling SRI 58 | 59 | To enable SRI support, configure the hash algorithm in your Rails application: 60 | 61 | ```ruby 62 | config.assets.integrity_hash_algorithm = "sha384" 63 | ``` 64 | 65 | Valid hash algorithms include: 66 | - `"sha256"` - SHA-256 (most common) 67 | - `"sha384"` - SHA-384 (recommended for enhanced security) 68 | - `"sha512"` - SHA-512 (strongest) 69 | 70 | ### Using SRI in your views 71 | 72 | Once configured, you can enable SRI by passing the `integrity: true` option to asset helpers: 73 | 74 | ```erb 75 | <%= stylesheet_link_tag "application", integrity: true %> 76 | <%= javascript_include_tag "application", integrity: true %> 77 | ``` 78 | 79 | This generates HTML with integrity hashes: 80 | 81 | ```html 82 | 84 | 86 | ``` 87 | 88 | **Important**: SRI only works in secure contexts (HTTPS) or during local development. The integrity hashes are automatically omitted when serving over HTTP in production for security reasons. 89 | 90 | ### Bulk stylesheet inclusion with SRI 91 | 92 | Propshaft extends `stylesheet_link_tag` with special symbols for bulk inclusion: 93 | 94 | ```erb 95 | <%= stylesheet_link_tag :all, integrity: true %> 96 | <%= stylesheet_link_tag :app, integrity: true %> 97 | ``` 98 | 99 | ## Improving performance in development 100 | 101 | Before every request Propshaft checks if any asset was updated to decide if a cache sweep is needed. This verification is done using the application's configured file watcher which, by default, is `ActiveSupport::FileUpdateChecker`. 102 | 103 | If you have a lot of assets in your project, you can improve performance by adding the `listen` gem to the development group in your Gemfile, and this line to the `development.rb` environment file: 104 | 105 | ```ruby 106 | config.file_watcher = ActiveSupport::EventedFileUpdateChecker 107 | ``` 108 | 109 | 110 | ## Migrating from Sprockets 111 | 112 | Propshaft does a lot less than Sprockets, by design, so it might well be a fair bit of work to migrate if it's even desirable. This is particularly true if you rely on Sprockets to provide any form of transpiling, like CoffeeScript or Sass, or if you rely on any gems that do. You'll need to either stop transpiling or use a Node-based transpiler, like those in [`jsbundling-rails`](https://github.com/rails/jsbundling-rails) and [`cssbundling-rails`](https://github.com/rails/cssbundling-rails). 113 | 114 | On the other hand, if you're already bundling JavaScript and CSS through a Node-based setup, then Propshaft is going to slot in easily. Since you don't need another tool to bundle or transpile. Just to digest and serve. 115 | 116 | But for greenfield apps using the default import-map approach, Propshaft can also work well, if you're able to deal with vanilla CSS. 117 | 118 | 119 | ## License 120 | 121 | Propshaft is released under the [MIT License](https://opensource.org/licenses/MIT). 122 | -------------------------------------------------------------------------------- /test/propshaft/manifest_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "propshaft/manifest" 3 | 4 | class Propshaft::ManifestTest < ActiveSupport::TestCase 5 | test "serializes to the extensible manifest format with integrity hash value" do 6 | manifest = create_manifest("sha384") 7 | parsed_manifest = JSON.parse(manifest.to_json) 8 | 9 | manifest_entry = parsed_manifest["one.txt"] 10 | assert_equal "one-f2e1ec14.txt", manifest_entry["digested_path"] 11 | assert_equal "sha384-LdS8l2QTAF8bD8WPb8QSQv0skTWHhmcnS2XU5LBkVQneGzqIqnDRskQtJvi7ADMe", manifest_entry["integrity"] 12 | 13 | manifest_entry = parsed_manifest["another.css"] 14 | assert_equal "another-c464b1ee.css", manifest_entry["digested_path"] 15 | assert_equal "sha384-jUiHGq2aPNACr4g68crM1I28TitXJKYhEgokcX6W5VYGwufEKQxfLpe4GakM84ex", manifest_entry["integrity"] 16 | end 17 | 18 | test "serializes to the extensible manifest format without integrity hash algorithm" do 19 | manifest = create_manifest 20 | parsed_manifest = JSON.parse(manifest.to_json) 21 | 22 | manifest_entry = parsed_manifest["one.txt"] 23 | assert_equal "one-f2e1ec14.txt", manifest_entry["digested_path"] 24 | assert_nil manifest_entry["integrity"] 25 | 26 | manifest_entry = parsed_manifest["another.css"] 27 | assert_equal "another-c464b1ee.css", manifest_entry["digested_path"] 28 | assert_nil manifest_entry["integrity"] 29 | end 30 | 31 | test "loads from new extensible manifest format" do 32 | manifest_path = Pathname.new("#{__dir__}/../fixtures/new_manifest_format/.manifest.json") 33 | manifest = Propshaft::Manifest.from_path(manifest_path) 34 | 35 | entry = manifest["one.txt"] 36 | assert_not_nil entry 37 | assert_equal "one.txt", entry.logical_path 38 | assert_equal "one-f2e1ec14.txt", entry.digested_path 39 | assert_equal "sha384-LdS8l2QTAF8bD8WPb8QSQv0skTWHhmcnS2XU5LBkVQneGzqIqnDRskQtJvi7ADMe", entry.integrity 40 | end 41 | 42 | test "loads from old simple manifest format" do 43 | manifest_path = Pathname.new("#{__dir__}/../fixtures/output/.manifest.json") 44 | manifest = Propshaft::Manifest.from_path(manifest_path) 45 | 46 | entry = manifest["one.txt"] 47 | assert_not_nil entry 48 | assert_equal "one.txt", entry.logical_path 49 | assert_equal "one-f2e1ec14.txt", entry.digested_path 50 | assert_nil entry.integrity 51 | end 52 | 53 | test "push method adds entry to manifest" do 54 | manifest = Propshaft::Manifest.new 55 | entry = Propshaft::Manifest::ManifestEntry.new( 56 | logical_path: "test.js", 57 | digested_path: "test-abc123.js", 58 | integrity: "sha384-test" 59 | ) 60 | 61 | manifest.push(entry) 62 | retrieved_entry = manifest["test.js"] 63 | 64 | assert_equal entry, retrieved_entry 65 | assert_equal "test.js", retrieved_entry.logical_path 66 | assert_equal "test-abc123.js", retrieved_entry.digested_path 67 | assert_equal "sha384-test", retrieved_entry.integrity 68 | end 69 | 70 | test "<< alias works for push method" do 71 | manifest = Propshaft::Manifest.new 72 | entry = Propshaft::Manifest::ManifestEntry.new( 73 | logical_path: "test.css", 74 | digested_path: "test-def456.css", 75 | integrity: nil 76 | ) 77 | 78 | manifest << entry 79 | retrieved_entry = manifest["test.css"] 80 | 81 | assert_equal entry, retrieved_entry 82 | assert_equal "test.css", retrieved_entry.logical_path 83 | assert_equal "test-def456.css", retrieved_entry.digested_path 84 | assert_nil retrieved_entry.integrity 85 | end 86 | 87 | test "[] accessor returns nil for missing entries" do 88 | manifest = Propshaft::Manifest.new 89 | assert_nil manifest["nonexistent.js"] 90 | end 91 | 92 | test "delete method removes entry and returns it" do 93 | manifest = Propshaft::Manifest.new 94 | entry = Propshaft::Manifest::ManifestEntry.new( 95 | logical_path: "test.js", 96 | digested_path: "test-abc123.js", 97 | integrity: "sha384-test" 98 | ) 99 | 100 | manifest.push(entry) 101 | assert_equal entry, manifest["test.js"] 102 | 103 | deleted_entry = manifest.delete("test.js") 104 | assert_equal entry, deleted_entry 105 | assert_nil manifest["test.js"] 106 | end 107 | 108 | test "delete method returns nil for missing entries" do 109 | manifest = Propshaft::Manifest.new 110 | assert_nil manifest.delete("nonexistent.js") 111 | end 112 | 113 | test "delete method with multiple entries" do 114 | manifest = Propshaft::Manifest.new 115 | 116 | entry1 = Propshaft::Manifest::ManifestEntry.new( 117 | logical_path: "app.js", 118 | digested_path: "app-abc123.js", 119 | integrity: "sha384-test1" 120 | ) 121 | 122 | entry2 = Propshaft::Manifest::ManifestEntry.new( 123 | logical_path: "style.css", 124 | digested_path: "style-def456.css", 125 | integrity: "sha384-test2" 126 | ) 127 | 128 | manifest.push(entry1) 129 | manifest.push(entry2) 130 | 131 | assert_equal entry1, manifest["app.js"] 132 | assert_equal entry2, manifest["style.css"] 133 | 134 | deleted_entry = manifest.delete("app.js") 135 | assert_equal entry1, deleted_entry 136 | assert_nil manifest["app.js"] 137 | assert_equal entry2, manifest["style.css"] 138 | end 139 | 140 | test "push_asset method creates entry from asset" do 141 | manifest = Propshaft::Manifest.new(integrity_hash_algorithm: "sha384") 142 | asset = find_asset("one.txt") 143 | 144 | manifest.push_asset(asset) 145 | entry = manifest["one.txt"] 146 | 147 | assert_not_nil entry 148 | assert_equal "one.txt", entry.logical_path 149 | assert_equal "one-f2e1ec14.txt", entry.digested_path 150 | assert_not_nil entry.integrity 151 | assert entry.integrity.start_with?("sha384-") 152 | end 153 | 154 | test "push_asset without integrity algorithm" do 155 | manifest = Propshaft::Manifest.new 156 | asset = find_asset("one.txt") 157 | 158 | manifest.push_asset(asset) 159 | entry = manifest["one.txt"] 160 | 161 | assert_not_nil entry 162 | assert_equal "one.txt", entry.logical_path 163 | assert_equal "one-f2e1ec14.txt", entry.digested_path 164 | assert_nil entry.integrity 165 | end 166 | 167 | test "transform_values applies block to all entries" do 168 | manifest = Propshaft::Manifest.new 169 | 170 | entry1 = Propshaft::Manifest::ManifestEntry.new( 171 | logical_path: "app.js", 172 | digested_path: "app-abc123.js", 173 | integrity: "sha384-test1" 174 | ) 175 | 176 | entry2 = Propshaft::Manifest::ManifestEntry.new( 177 | logical_path: "style.css", 178 | digested_path: "style-def456.css", 179 | integrity: nil 180 | ) 181 | 182 | manifest.push(entry1) 183 | manifest.push(entry2) 184 | 185 | # Transform to get digested_path 186 | hash = manifest.transform_values { |entry| entry.digested_path } 187 | assert_equal({ "app.js" => "app-abc123.js", "style.css" => "style-def456.css" }, hash) 188 | 189 | # Transform to get integrity 190 | hash = manifest.transform_values { |entry| entry.integrity } 191 | assert_equal({ "app.js" => "sha384-test1", "style.css" => nil }, hash) 192 | 193 | # Transform to get logical_path (for demonstration) 194 | hash = manifest.transform_values { |entry| entry.logical_path } 195 | assert_equal({ "app.js" => "app.js", "style.css" => "style.css" }, hash) 196 | end 197 | 198 | test "transform_values returns empty hash for empty manifest" do 199 | manifest = Propshaft::Manifest.new 200 | assert_equal({}, manifest.transform_values { |entry| entry.digested_path }) 201 | end 202 | 203 | private 204 | def create_manifest(integrity_hash_algorithm = nil) 205 | Propshaft::Manifest.new(integrity_hash_algorithm:).tap do |manifest| 206 | manifest.push_asset(find_asset("one.txt")) 207 | manifest.push_asset(find_asset("another.css")) 208 | end 209 | end 210 | 211 | def find_asset(logical_path) 212 | root_path = Pathname.new("#{__dir__}/../fixtures/assets/first_path") 213 | path = root_path.join(logical_path) 214 | 215 | assembly = Propshaft::Assembly.new(ActiveSupport::OrderedOptions.new.tap { |config| 216 | config.paths = [ root_path ] 217 | config.compilers = [[ "text/css", Propshaft::Compiler::CssAssetUrls ]] 218 | }) 219 | 220 | Propshaft::Asset.new(path, logical_path: logical_path, load_path: assembly.load_path) 221 | end 222 | end 223 | -------------------------------------------------------------------------------- /test/propshaft/helper_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Propshaft::HelperTest < ActionView::TestCase 4 | test "asset_integrity returns SHA256 hash for existing asset" do 5 | integrity = asset_integrity("hello_world.js") 6 | assert_equal "sha384-BIr0kyMRq2sfytK/T0XlGjfav9ZZrWkSBC2yHVunCchnkpP83H28/UtHw+m9iNHO", integrity 7 | end 8 | 9 | test "asset_integrity with asset type option" do 10 | integrity = asset_integrity("hello_world", type: :stylesheet) 11 | assert_equal "sha384-ZSAt6UaTZ1OYvSB1fr2WXE8izMW4qnd17BZ1zaZ3TpAdIw3VEUmyupHd/k/cMCqM", integrity 12 | end 13 | 14 | test "compute_asset_path returns resolved path for existing asset" do 15 | path = compute_asset_path("hello_world.js") 16 | assert_equal "/assets/hello_world-888761f8.js", path 17 | end 18 | 19 | test "compute_asset_path raises MissingAssetError for nonexistent asset" do 20 | error = assert_raises(Propshaft::MissingAssetError) do 21 | compute_asset_path("nonexistent.txt") 22 | end 23 | assert_equal "The asset 'nonexistent.txt' was not found in the load path.", error.message 24 | end 25 | 26 | test "stylesheet_link_tag with integrity in secure context" do 27 | request.headers["HTTPS"] = "on" 28 | 29 | result = stylesheet_link_tag("hello_world", integrity: true) 30 | 31 | assert_dom_equal(<<~HTML, result) 32 | 37 | HTML 38 | end 39 | 40 | test "stylesheet_link_tag with integrity in local context" do 41 | request.remote_addr = "127.0.0.1" 42 | 43 | result = stylesheet_link_tag("hello_world", integrity: true) 44 | 45 | assert_dom_equal(<<~HTML, result) 46 | 51 | HTML 52 | end 53 | 54 | test "stylesheet_link_tag without integrity in insecure context" do 55 | result = stylesheet_link_tag("hello_world", integrity: true) 56 | 57 | assert_dom_equal(<<~HTML, result) 58 | 62 | HTML 63 | end 64 | 65 | test "stylesheet_link_tag without request context" do 66 | request.remote_addr = "127.0.0.1" 67 | @request = nil 68 | 69 | result = stylesheet_link_tag("hello_world", integrity: true) 70 | 71 | assert_dom_equal(<<~HTML, result) 72 | 76 | HTML 77 | end 78 | 79 | test "stylesheet_link_tag with multiple sources and integrity" do 80 | request.headers["HTTPS"] = "on" 81 | 82 | result = stylesheet_link_tag("hello_world", "goodbye", integrity: true) 83 | 84 | assert_dom_equal(<<~HTML, result) 85 | 90 | 95 | HTML 96 | end 97 | 98 | test "stylesheet_link_tag with :all option" do 99 | result = stylesheet_link_tag(:all) 100 | 101 | assert_dom_equal(<<~HTML, result) 102 | 103 | 104 | 105 | HTML 106 | end 107 | 108 | test "stylesheet_link_tag with :app option" do 109 | result = stylesheet_link_tag(:app) 110 | 111 | assert_dom_equal(<<~HTML, result) 112 | 113 | 114 | HTML 115 | end 116 | 117 | test "stylesheet_link_tag with additional options" do 118 | result = stylesheet_link_tag( 119 | "hello_world", 120 | media: "print", 121 | data: { turbo_track: "reload" } 122 | ) 123 | 124 | assert_dom_equal(<<~HTML, result) 125 | 131 | HTML 132 | end 133 | 134 | test "stylesheet_link_tag should extract options from the sources" do 135 | result = stylesheet_link_tag( 136 | "hello_world", 137 | { 138 | media: "print", 139 | data: { turbo_track: "reload" } 140 | } 141 | ) 142 | 143 | assert_dom_equal(<<~HTML, result) 144 | 150 | HTML 151 | end 152 | 153 | test "javascript_include_tag with integrity in secure context" do 154 | request.headers["HTTPS"] = "on" 155 | 156 | result = javascript_include_tag("hello_world", integrity: true) 157 | 158 | assert_dom_equal(<<~HTML, result) 159 | 163 | HTML 164 | end 165 | 166 | test "javascript_include_tag with integrity in local context" do 167 | request.remote_addr = "127.0.0.1" 168 | 169 | result = javascript_include_tag("hello_world", integrity: true) 170 | 171 | assert_dom_equal(<<~HTML, result) 172 | 176 | HTML 177 | end 178 | 179 | test "javascript_include_tag without integrity in insecure context" do 180 | result = javascript_include_tag("hello_world", integrity: true) 181 | 182 | assert_dom_equal(<<~HTML, result) 183 | 186 | HTML 187 | end 188 | 189 | test "javascript_include_tag with multiple sources and integrity" do 190 | request.headers["HTTPS"] = "on" 191 | 192 | result = javascript_include_tag("hello_world", "hello_world", integrity: true) 193 | 194 | assert_dom_equal(<<~HTML, result) 195 | 199 | 203 | HTML 204 | end 205 | 206 | test "javascript_include_tag with additional options" do 207 | result = javascript_include_tag( 208 | "hello_world", 209 | defer: true, 210 | data: { turbo_track: "reload" } 211 | ) 212 | 213 | assert_dom_equal(<<~HTML, result) 214 | 219 | HTML 220 | end 221 | 222 | test "javascript_include_tag should extract options from the sources" do 223 | result = javascript_include_tag( 224 | "hello_world", 225 | { 226 | defer: true, 227 | data: { turbo_track: "reload" } 228 | } 229 | ) 230 | 231 | assert_dom_equal(<<~HTML, result) 232 | 237 | HTML 238 | end 239 | 240 | test "all_stylesheets_paths returns array of CSS asset paths" do 241 | paths = all_stylesheets_paths 242 | 243 | assert_equal( 244 | [ 245 | "goodbye.css", 246 | "hello_world.css", 247 | "library.css" 248 | ], 249 | paths 250 | ) 251 | end 252 | 253 | test "app_stylesheets_paths returns array of app CSS asset paths" do 254 | paths = app_stylesheets_paths 255 | 256 | assert_equal( 257 | [ 258 | "goodbye.css", 259 | "hello_world.css" 260 | ], 261 | paths 262 | ) 263 | end 264 | 265 | test "asset_integrity handles file extensions correctly" do 266 | integrity1 = asset_integrity("hello_world.css") 267 | 268 | integrity2 = asset_integrity("hello_world", type: :stylesheet) 269 | 270 | assert_equal integrity1, integrity2 271 | end 272 | 273 | test "integrity option false explicitly disables integrity" do 274 | request.headers["HTTPS"] = "on" 275 | 276 | result = stylesheet_link_tag("hello_world", integrity: false) 277 | 278 | assert_dom_equal(<<~HTML, result) 279 | 283 | HTML 284 | end 285 | 286 | test "integrity option nil does not enable integrity" do 287 | request.headers["HTTPS"] = "on" 288 | 289 | result = stylesheet_link_tag("hello_world", integrity: nil) 290 | 291 | assert_dom_equal(<<~HTML, result) 292 | 296 | HTML 297 | end 298 | end 299 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | # Upgrading from Sprockets to Propshaft 2 | 3 | Propshaft has a smaller scope than Sprockets, therefore migrating to it will also require you to adopt the [jsbundling-rails](https://github.com/rails/jsbundling-rails) and [cssbundling-rails](https://github.com/rails/cssbundling-rails) gems. This guide will assume your project follows Rails 6.1 conventions of using [webpacker](https://github.com/rails/webpacker) to bundle javascript, [sass-rails](https://github.com/rails/sass-rails) to bundle css and [sprockets](https://github.com/rails/sprockets) to digest assets. Finally, you will also need [npx](https://docs.npmjs.com/cli/v7/commands/npx) version 7.1.0 or later installed. 4 | 5 | Propshaft depends on Rails 7, so you will need to upgrade to Rails 7+ before starting the migration. 6 | 7 | ## 1. Migrate from Webpacker to jsbundling-rails 8 | 9 | Start by following these steps: 10 | 11 | 1. Replace `webpacker` with `jsbundling-rails` in your Gemfile; 12 | 2. Run `./bin/bundle install`; 13 | 3. Run `./bin/rails javascript:install:webpack`; 14 | 4. Remove the file `config/initializers/assets.rb`; 15 | 5. Remove the file `bin/webpack`; 16 | 6. Remove the file `bin/webpack-dev-server`; 17 | 7. Remove the folder `config/webpack` (note: any custom configuration should be migrated to the new `webpack.config.js` file); 18 | 8. Remove the file `config/webpacker.yml`; 19 | 9. Replace all instances of `javascript_pack_tag` with `javascript_include_tag` and add `defer: true` to them. 20 | 21 | After you are done you will notice that the install step added various files to your project and updated some of the existing ones. 22 | 23 | **The new 'bin/dev' and 'Procfile.dev' files** 24 | 25 | The `./bin/dev` file is a shell script that uses [foreman](https://github.com/ddollar/foreman) and `Procfile.dev` to start two processes in a single terminal: `rails s` and `yarn build`. The latter replaces `webpack-dev-server` for bundling and watching for changes in javascript files. 26 | 27 | **The 'build' attribute added to package.json** 28 | 29 | This is the command that `yarn build` will use to bundle javascript files. 30 | 31 | **The new 'webpack.config.js' file** 32 | 33 | In `webpacker` this file was hidden inside the gem, but now you can edit it directly. If you had custom configuration in `config/webpack` you can move them to here. Projects with multiple entrypoints will need to adjust the `entry` attribute: 34 | 35 | ```js 36 | module.exports = { 37 | entry: { 38 | application: "./app/javascript/application.js", 39 | admin: "./app/javascript/admin.js" 40 | } 41 | } 42 | ``` 43 | 44 | **The 'link_tree' directive added to 'app/assets/manifest.js'** 45 | 46 | This tells Sprockets to include the files in `app/assets/builds` during `assets:precompile`. This is the folder where `yarn build` will place the bundled files, so make sure you commit it to the repository and don't delete it when cleaning assets. 47 | 48 | **What about babel?** 49 | 50 | If you would like to continue using babel for transpiling, you will need to configure it manually. First, open `webpack.config.js` and add this: 51 | 52 | ```js 53 | module.exports = { 54 | module: { 55 | rules: [ 56 | { 57 | test: /\.(js)$/, 58 | exclude: /node_modules/, 59 | use: ['babel-loader'] 60 | } 61 | ] 62 | } 63 | } 64 | ``` 65 | 66 | Then open `package.json` and add this: 67 | ```json 68 | "babel": { 69 | "presets": [ 70 | "./webpack.babel.js" 71 | ] 72 | } 73 | ``` 74 | 75 | Finally, download [webpackers babel preset](https://github.com/rails/webpacker/blob/master/package/babel/preset.js) file and place it in the same directory as `package.json` with the name `webpack.babel.js`. 76 | 77 | **Module resolution** 78 | 79 | Webpacker included the `source_path` (default: `app/javascript/`) into module resolution, so a statement like `import 'channels'` imported `app/javascript/channels/`. After migrating to `jsbundling-rails` this is no longer the case. You will need to update your `webpack.config.js` to include the following if you wish to maintain that behavior: 80 | 81 | ```javascript 82 | module.exports = { 83 | // ... 84 | resolve: { 85 | modules: ["app/javascript", "node_modules"], 86 | }, 87 | //... 88 | } 89 | ``` 90 | 91 | Alternatively, you can change modules to use relative imports, for example: 92 | ```diff 93 | - import 'channels' 94 | + import './channels' 95 | ``` 96 | 97 | ### Extracting Sass/SCSS from JavaScript 98 | 99 | In webpacker it is possible to extract Sass/SCSS from JavaScript by enabling `extract_css` in `webpacker.yml`. This allows for including those source files in JavaScript, e.g. `import '../scss/application.scss` 100 | 101 | If you wish to keep this functionality follow these steps: 102 | 103 | 1. Run `yarn add mini-css-extract-plugin sass sass-loader css-loader`; 104 | 2. Update your `webpack.config.js` to require `mini-css-extract-plugin` and configure the loaders (see example below). 105 | 106 | Example `webpack.config.js`: 107 | 108 | ```javascript 109 | const path = require("path") 110 | const webpack = require("webpack") 111 | const MiniCssExtractPlugin = require("mini-css-extract-plugin") 112 | 113 | module.exports = { 114 | mode: "production", 115 | devtool: "source-map", 116 | entry: { 117 | application: "./app/javascript/application.js" 118 | }, 119 | resolve: { 120 | modules: ["app/javascript", "node_modules"], 121 | }, 122 | output: { 123 | filename: "[name].js", 124 | sourceMapFilename: "[file].map", 125 | path: path.resolve(__dirname, "app/assets/builds"), 126 | }, 127 | plugins: [ 128 | new MiniCssExtractPlugin(), 129 | new webpack.optimize.LimitChunkCountPlugin({ 130 | maxChunks: 1 131 | }) 132 | ], 133 | module: { 134 | rules: [ 135 | { 136 | test: /\.s[ac]ss$/i, 137 | use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"], 138 | }, 139 | ], 140 | }, 141 | } 142 | ``` 143 | 144 | ## 2. Migrate from sass-rails to cssbundling-rails 145 | 146 | Note: if your application used Webpacker's `extract_css` to build your CSS and did not require `sass-rails`, you can skip this section. 147 | 148 | Start by following these steps: 149 | 150 | 1. Add `cssbundling-rails` to your Gemfile; 151 | 2. Run `./bin/bundle install`; 152 | 3. Run `./bin/rails css:install:sass`. 153 | 154 | After you are done you will notice that the install step updated some files. 155 | 156 | **The new process in 'Procfile.dev'** 157 | 158 | Just like the javascript process, this one will bundle and watch for changes in css files. 159 | 160 | **The 'build:css' attribute added to package.json** 161 | 162 | This is the command `yarn build` will use to bundle css files. 163 | 164 | **The 'link_tree' directive removed from 'app/assets/manifest.js'** 165 | 166 | Now that the CSS files will be placed into `app/assets/build`, Sprockets no longer needs to worry about the `app/assets/stylesheets` folder. If you have any other `link_tree` for css files, remove them too. 167 | 168 | ### Configuring multiple entrypoints 169 | 170 | Sprockets will only compile files in the root directories listed in `manifest.js`, but the sass package that `yarn build` uses will also check subfolders, which might cause compilation errors if your scss files are using features like `@import` and variables. This means that if you have multiple entry points in your app, you have some extra work ahead of you. 171 | 172 | Let's assume you have the following structure in your `app/asset/stylesheets` folder: 173 | 174 | ``` 175 | stylesheets/admin.scss 176 | stylesheets/admin/source_1.scss 177 | stylesheets/admin/source_2.scss 178 | stylesheets/application.scss 179 | stylesheets/application/source_1.scss 180 | stylesheets/application/source_2.scss 181 | ``` 182 | 183 | Start by your separating your entrypoints from your other files, and adjusting all `@import` for the new structure: 184 | 185 | ``` 186 | stylesheets/entrypoints/admin.scss 187 | stylesheets/entrypoints/application.scss 188 | stylesheets/sources/admin/source_1.scss 189 | stylesheets/sources/admin/source_2.scss 190 | stylesheets/sources/application/source_1.scss 191 | stylesheets/sources/application/source_2.scss 192 | ``` 193 | 194 | Then adjust the `build` attribute in `package.json`: 195 | ``` 196 | "build:css": "sass ./app/assets/stylesheets/entrypoints:./app/assets/builds --no-source-map --load-path=node_modules" 197 | ``` 198 | 199 | ### Deprecation warnings 200 | 201 | Sass might raise deprecation warnings depending on what features you are using (such as division), but the messages will explain how to fix them. If you are not sure, see more details in the [official documentation](https://sass-lang.com/documentation/breaking-changes). 202 | 203 | ## 3. Migrate from Sprockets to Propshaft 204 | 205 | Start by following these steps: 206 | 207 | 1. Remove `sprockets`, `sprockets-rails`, and `sass-rails` from the Gemfile and add `propshaft`; 208 | 2. Run `./bin/bundle install`; 209 | 3. Check your `Gemfile.lock`, repeat steps 1 and 2 for gems that list `sprockets` or `sprockets-rails` as a dependency; 210 | 4. Open `config/application.rb` and remove `config.assets.paths << Rails.root.join('app','assets')`; 211 | 5. Remove `app/assets/config/manifest.js`. 212 | 6. Replace all asset_helpers (`image_url`, `font_url`) in css files with standard `urls`. 213 | 7. If you are importing only the frameworks you need (instead of `rails/all`), remove `require "sprockets/railtie"`; 214 | 215 | ### Asset paths 216 | 217 | Propshaft will automatically include in its search paths the folders `vendor/assets`, `lib/assets` and `app/assets` of your project and of all the gems in your Gemfile. You can see all included files by using the `reveal` rake task: 218 | ``` 219 | rake assets:reveal 220 | ``` 221 | 222 | ### Asset helpers 223 | 224 | Propshaft does not rely on asset_helpers (`asset_path`, `asset_url`, `image_url`, etc.) like Sprockets did. Instead, it will search for every `url` function in your css files, and adjust them to include the digest of the assets they reference. 225 | 226 | Go through your css files, and make the necessary adjustments: 227 | ```diff 228 | - background: image_url('hero.jpg'); 229 | + background: url('/hero.jpg'); 230 | ``` 231 | 232 | Notice that Propshaft's version starts with an `/` and Sprockets' version does not? That's because the latter uses **absolute paths**, and the former uses **relative paths**. To better illustrate that difference, let's assume you have the following structure: 233 | 234 | ``` 235 | assets/stylesheets/theme/main.scss 236 | assets/images/hero.jpg 237 | ``` 238 | 239 | In Sprockets, `main.scss` can reference `hero.jpg` like this: 240 | ```css 241 | background: image_url('hero.jpg') 242 | ``` 243 | 244 | Using the same path with `url` in Propshaft will cause it to raise an error, saying it cannot locate `theme/hero.jpg`. That's because Propshaft assumes all paths are relative to the path of the file it's processing. Since it was processing a css file inside the `theme` folder, it will also look for `hero.jpg` in the same folder. 245 | 246 | By adding a `/` at the start of the path we are telling Propshaft to consider this path as an absolute path. While this change in behavior increases the work a bit when upgrading, it makes **external libraries like FontAwesome and Bootstrap themes work out-of-the-box**. 247 | 248 | ### Asset content 249 | 250 | It's a common pattern in apps to inline small SVG files and low resolution versions of images that need to be displayed as quickly as possible. In Propshaft, the same line of code works for all environments: 251 | ```ruby 252 | Rails.application.assets.load_path.find('logo.svg').content 253 | ``` 254 | 255 | As Rails escapes html tags in views by default, in order to output a rendered svg you will need to specify rails not to escape the string using [html_safe](https://api.rubyonrails.org/classes/String.html#method-i-html_safe) or [raw](https://api.rubyonrails.org/classes/ActionView/Helpers/OutputSafetyHelper.html#method-i-raw). 256 | ```ruby 257 | Rails.application.assets.load_path.find('logo.svg').content.html_safe 258 | raw Rails.application.assets.load_path.find('logo.svg').content 259 | ``` 260 | 261 | ### Precompilation in development 262 | 263 | Propshaft uses a dynamic assets resolver in development mode. However, when you run `assets:precompile` locally Propshaft will then switch to a static assets resolver. Therefore, changes to assets will not be observed anymore and you will have to precompile the assets each time changes are made. This is different to Sprockets. 264 | 265 | If you wish to have dynamic assets resolver enabled again, you need to clean your target folder (usually `public/assets`) and propshaft will start serving dynamic content from source. One way to do this is to run `rails assets:clobber`. 266 | 267 | Another way to watch changes in your CSS & JS assets is by running `bin/dev` command instead of `rails server` that not only runs the server but also keeps looking for any changes in the assets and once it detects any changes, it compiles them while the server is running. This is possible because of the `Procfile.dev`. 268 | --------------------------------------------------------------------------------