├── 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 |
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 |
--------------------------------------------------------------------------------