diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ba5e304..0422852 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -37,6 +37,7 @@ jobs:
env:
BUNDLE_GEMFILE: gemfiles/rails_${{ matrix.rails-version }}_${{ matrix.assets-pipeline }}.gemfile
+ ASSETS_PIPELINE: ${{ matrix.assets-pipeline }}
steps:
- uses: actions/checkout@v4
diff --git a/Appraisals b/Appraisals
index 6843f03..e2be1b6 100644
--- a/Appraisals
+++ b/Appraisals
@@ -17,7 +17,6 @@ end
appraise "rails_7.0_propshaft" do
gem "rails", github: "rails/rails", branch: "7-0-stable"
- gem "propshaft"
gem "sqlite3", "~> 1.4"
end
@@ -29,7 +28,6 @@ end
appraise "rails_7.1_propshaft" do
gem "rails", "~> 7.1.0"
- gem "propshaft"
end
appraise "rails_7.2_sprockets" do
@@ -40,7 +38,6 @@ end
appraise "rails_7.2_propshaft" do
gem "rails", "~> 7.2.0"
- gem "propshaft"
end
appraise "rails_8.0_sprockets" do
@@ -51,7 +48,6 @@ end
appraise "rails_8.0_propshaft" do
gem "rails", "~> 8.0.0"
- gem "propshaft"
end
appraise "rails_main_sprockets" do
@@ -62,5 +58,4 @@ end
appraise "rails_main_propshaft" do
gem "rails", github: "rails/rails", branch: "main"
- gem "propshaft"
end
diff --git a/Gemfile b/Gemfile
index a144c5e..cdd020f 100644
--- a/Gemfile
+++ b/Gemfile
@@ -5,7 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gemspec
gem "rails"
-gem "propshaft"
+gem "propshaft", ">= 1.2.0"
gem "sqlite3"
diff --git a/Gemfile.lock b/Gemfile.lock
index 09d05d7..150f47f 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -105,13 +105,13 @@ GEM
crass (1.0.6)
date (3.4.1)
drb (2.2.3)
- erb (5.0.1)
+ erb (5.0.2)
erubi (1.13.1)
globalid (1.2.1)
activesupport (>= 6.1)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
- io-console (0.8.0)
+ io-console (0.8.1)
irb (1.15.2)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
@@ -150,11 +150,10 @@ GEM
pp (0.6.2)
prettyprint
prettyprint (0.2.0)
- propshaft (1.1.0)
+ propshaft (1.2.0)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
- railties (>= 7.0.0)
psych (5.2.6)
date
stringio
@@ -257,7 +256,7 @@ DEPENDENCIES
byebug
capybara
importmap-rails!
- propshaft
+ propshaft (>= 1.2.0)
rails
rexml
selenium-webdriver
diff --git a/README.md b/README.md
index 30e10bf..bc2b2b2 100644
--- a/README.md
+++ b/README.md
@@ -44,7 +44,7 @@ import React from "./node_modules/react"
import React from "https://ga.jspm.io/npm:react@17.0.1/index.js"
```
-Importmap-rails provides a clean API for mapping "bare module specifiers" like `"react"`
+Importmap-rails provides a clean API for mapping "bare module specifiers" like `"react"`
to 1 of the 3 viable ways of loading ES Module javascript packages.
For example:
@@ -54,11 +54,11 @@ For example:
pin "react", to: "https://ga.jspm.io/npm:react@17.0.2/index.js"
```
-means "everytime you see `import React from "react"`
+means "every time you see `import React from "react"`
change it to `import React from "https://ga.jspm.io/npm:react@17.0.2/index.js"`"
```js
-import React from "react"
+import React from "react"
// => import React from "https://ga.jspm.io/npm:react@17.0.2/index.js"
```
@@ -79,10 +79,15 @@ If you want to import local js module files from `app/javascript/src` or other s
```rb
# config/importmap.rb
pin_all_from 'app/javascript/src', under: 'src', to: 'src'
+
+# With automatic integrity calculation for enhanced security
+pin_all_from 'app/javascript/controllers', under: 'controllers', integrity: true
```
The `:to` parameter is only required if you want to change the destination logical import name. If you drop the :to option, you must place the :under option directly after the first parameter.
+The `integrity: true` option automatically calculates integrity hashes for all files in the directory, providing security benefits without manual hash management.
+
Allows you to:
```js
@@ -131,6 +136,137 @@ If you later wish to remove a downloaded pin:
Unpinning and removing "react"
```
+## Subresource Integrity (SRI)
+
+For enhanced security, importmap-rails automatically includes [Subresource Integrity (SRI)](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) hashes by default when pinning packages. This ensures that JavaScript files loaded from CDNs haven't been tampered with.
+
+### Default behavior with integrity
+
+When you pin a package, integrity hashes are automatically included:
+
+```bash
+./bin/importmap pin lodash
+Pinning "lodash" to vendor/javascript/lodash.js via download from https://ga.jspm.io/npm:lodash@4.17.21/lodash.js
+ Using integrity: sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF
+```
+
+This generates a pin in your `config/importmap.rb` with the integrity hash:
+
+```ruby
+pin "lodash", integrity: "sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF" # @4.17.21
+```
+
+### Opting out of integrity
+
+If you need to disable integrity checking (not recommended for security reasons), you can use the `--no-integrity` flag:
+
+```bash
+./bin/importmap pin lodash --no-integrity
+Pinning "lodash" to vendor/javascript/lodash.js via download from https://ga.jspm.io/npm:lodash@4.17.21/lodash.js
+```
+
+This generates a pin without integrity:
+
+```ruby
+pin "lodash" # @4.17.21
+```
+
+### Adding integrity to existing pins
+
+If you have existing pins without integrity hashes, you can add them using the `integrity` command:
+
+```bash
+# Add integrity to specific packages
+./bin/importmap integrity lodash react
+
+# Add integrity to all pinned packages
+./bin/importmap integrity
+
+# Update your importmap.rb file with integrity hashes
+./bin/importmap integrity --update
+```
+
+### Automatic integrity for local assets
+
+For local assets served by the Rails asset pipeline (like those created with `pin` or `pin_all_from`), you can use `integrity: true` to automatically calculate integrity hashes from the compiled assets:
+
+```ruby
+# config/importmap.rb
+
+# Automatically calculate integrity from asset pipeline
+pin "application", integrity: true
+pin "admin", to: "admin.js", integrity: true
+
+# Works with pin_all_from too
+pin_all_from "app/javascript/controllers", under: "controllers", integrity: true
+pin_all_from "app/javascript/lib", under: "lib", integrity: true
+
+# Mixed usage
+pin "local_module", integrity: true # Auto-calculated
+pin "cdn_package", integrity: "sha384-abc123..." # Pre-calculated
+pin "no_integrity_package" # No integrity (default)
+```
+
+This is particularly useful for:
+* **Local JavaScript files** managed by your Rails asset pipeline
+* **Bulk operations** with `pin_all_from` where calculating hashes manually would be tedious
+* **Development workflow** where asset contents change frequently
+
+The `integrity: true` option:
+* Uses the Rails asset pipeline's built-in integrity calculation
+* Works with both Sprockets and Propshaft
+* Automatically updates when assets are recompiled
+* Gracefully handles missing assets (returns `nil` for non-existent files)
+
+**Example output with `integrity: true`:**
+```json
+{
+ "imports": {
+ "application": "/assets/application-abc123.js",
+ "controllers/hello_controller": "/assets/controllers/hello_controller-def456.js"
+ },
+ "integrity": {
+ "/assets/application-abc123.js": "sha256-xyz789...",
+ "/assets/controllers/hello_controller-def456.js": "sha256-uvw012..."
+ }
+}
+```
+
+### How integrity works
+
+The integrity hashes are automatically included in your import map and module preload tags:
+
+**Import map JSON:**
+```json
+{
+ "imports": {
+ "lodash": "https://ga.jspm.io/npm:lodash@4.17.21/lodash.js"
+ },
+ "integrity": {
+ "https://ga.jspm.io/npm:lodash@4.17.21/lodash.js": "sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF"
+ }
+}
+```
+
+**Module preload tags:**
+```html
+
+```
+
+Modern browsers will automatically validate these integrity hashes when loading the JavaScript modules, ensuring the files haven't been modified.
+
+### Redownloading packages with integrity
+
+The `pristine` command also includes integrity by default:
+
+```bash
+# Redownload all packages with integrity (default)
+./bin/importmap pristine
+
+# Redownload packages without integrity
+./bin/importmap pristine --no-integrity
+```
+
## Preloading pinned modules
To avoid the waterfall effect where the browser has to load one file after another before it can get to the deepest nested import, importmap-rails uses [modulepreload links](https://developers.google.com/web/updates/2017/12/modulepreload) by default. If you don't want to preload a dependency, because you want to load it on-demand for efficiency, append `preload: false` to the pin.
@@ -217,7 +353,7 @@ Pin your js file:
pin "checkout", preload: false
```
-Import your module on the specific page. Note: you'll likely want to use a `content_for` block on the specifc page/partial, then yield it in your layout.
+Import your module on the specific page. Note: you'll likely want to use a `content_for` block on the specific page/partial, then yield it in your layout.
```erb
<% content_for :head do %>
diff --git a/app/helpers/importmap/importmap_tags_helper.rb b/app/helpers/importmap/importmap_tags_helper.rb
index fee48a3..295b6dc 100644
--- a/app/helpers/importmap/importmap_tags_helper.rb
+++ b/app/helpers/importmap/importmap_tags_helper.rb
@@ -25,13 +25,23 @@ def javascript_import_module_tag(*module_names)
# (defaults to Rails.application.importmap), such that they'll be fetched
# in advance by browsers supporting this link type (https://caniuse.com/?search=modulepreload).
def javascript_importmap_module_preload_tags(importmap = Rails.application.importmap, entry_point: "application")
- javascript_module_preload_tag(*importmap.preloaded_module_paths(resolver: self, entry_point:, cache_key: entry_point))
+ packages = importmap.preloaded_module_packages(resolver: self, entry_point:, cache_key: entry_point)
+
+ _generate_preload_tags(packages) { |path, package| [path, { integrity: package.integrity }] }
end
# Link tag(s) for preloading the JavaScript module residing in `*paths`. Will return one link tag per path element.
def javascript_module_preload_tag(*paths)
- safe_join(Array(paths).collect { |path|
- tag.link rel: "modulepreload", href: path, nonce: request&.content_security_policy_nonce
- }, "\n")
+ _generate_preload_tags(paths) { |path| [path, {}] }
end
+
+ private
+ def _generate_preload_tags(items)
+ content_security_policy_nonce = request&.content_security_policy_nonce
+
+ safe_join(Array(items).collect { |item|
+ path, options = yield(item)
+ tag.link rel: "modulepreload", href: path, nonce: content_security_policy_nonce, **options
+ }, "\n")
+ end
end
diff --git a/gemfiles/rails_7.0_propshaft.gemfile b/gemfiles/rails_7.0_propshaft.gemfile
index 6096f3a..896a5b6 100644
--- a/gemfiles/rails_7.0_propshaft.gemfile
+++ b/gemfiles/rails_7.0_propshaft.gemfile
@@ -3,7 +3,7 @@
source "https://rubygems.org"
gem "rails", branch: "7-0-stable", git: "https://github.com/rails/rails.git"
-gem "propshaft"
+gem "propshaft", ">= 1.2.0"
gem "sqlite3", "~> 1.4"
group :development do
diff --git a/gemfiles/rails_7.1_propshaft.gemfile b/gemfiles/rails_7.1_propshaft.gemfile
index df7a907..e695ca4 100644
--- a/gemfiles/rails_7.1_propshaft.gemfile
+++ b/gemfiles/rails_7.1_propshaft.gemfile
@@ -3,7 +3,7 @@
source "https://rubygems.org"
gem "rails", "~> 7.1.0"
-gem "propshaft"
+gem "propshaft", ">= 1.2.0"
gem "sqlite3"
group :development do
diff --git a/gemfiles/rails_7.2_propshaft.gemfile b/gemfiles/rails_7.2_propshaft.gemfile
index c081234..dff1f0c 100644
--- a/gemfiles/rails_7.2_propshaft.gemfile
+++ b/gemfiles/rails_7.2_propshaft.gemfile
@@ -3,7 +3,7 @@
source "https://rubygems.org"
gem "rails", "~> 7.2.0"
-gem "propshaft"
+gem "propshaft", ">= 1.2.0"
gem "sqlite3"
group :development do
diff --git a/gemfiles/rails_8.0_propshaft.gemfile b/gemfiles/rails_8.0_propshaft.gemfile
index 034cb71..c6acfd3 100644
--- a/gemfiles/rails_8.0_propshaft.gemfile
+++ b/gemfiles/rails_8.0_propshaft.gemfile
@@ -3,7 +3,7 @@
source "https://rubygems.org"
gem "rails", "~> 8.0.0"
-gem "propshaft"
+gem "propshaft", ">= 1.2.0"
gem "sqlite3"
group :development do
diff --git a/gemfiles/rails_main_propshaft.gemfile b/gemfiles/rails_main_propshaft.gemfile
index 3de5eec..a29a396 100644
--- a/gemfiles/rails_main_propshaft.gemfile
+++ b/gemfiles/rails_main_propshaft.gemfile
@@ -3,7 +3,7 @@
source "https://rubygems.org"
gem "rails", branch: "main", git: "https://github.com/rails/rails.git"
-gem "propshaft"
+gem "propshaft", ">= 1.2.0"
gem "sqlite3"
group :development do
diff --git a/lib/importmap/commands.rb b/lib/importmap/commands.rb
index d3fda40..3b5fd55 100644
--- a/lib/importmap/commands.rb
+++ b/lib/importmap/commands.rb
@@ -13,21 +13,19 @@ def self.exit_on_failure?
option :env, type: :string, aliases: :e, default: "production"
option :from, type: :string, aliases: :f, default: "jspm"
option :preload, type: :string, repeatable: true, desc: "Can be used multiple times"
+ option :integrity, type: :boolean, aliases: :i, default: true, desc: "Include integrity hash from JSPM"
def pin(*packages)
- if imports = packager.import(*packages, env: options[:env], from: options[:from])
- imports.each do |package, url|
+ with_import_response(packages, env: options[:env], from: options[:from], integrity: options[:integrity]) do |imports, integrity_hashes|
+ process_imports(imports, integrity_hashes) do |package, url, integrity_hash|
puts %(Pinning "#{package}" to #{packager.vendor_path}/#{package}.js via download from #{url})
+
packager.download(package, url)
- pin = packager.vendored_pin_for(package, url, options[:preload])
- if packager.packaged?(package)
- gsub_file("config/importmap.rb", /^pin "#{package}".*$/, pin, verbose: false)
- else
- append_to_file("config/importmap.rb", "#{pin}\n", verbose: false)
- end
+ pin = packager.vendored_pin_for(package, url, options[:preload], integrity: integrity_hash)
+
+ log_integrity_usage(integrity_hash)
+ update_importmap_with_pin(package, pin)
end
- else
- puts "Couldn't find any packages in #{packages.inspect} on #{options[:from]}"
end
end
@@ -35,33 +33,31 @@ def pin(*packages)
option :env, type: :string, aliases: :e, default: "production"
option :from, type: :string, aliases: :f, default: "jspm"
def unpin(*packages)
- if imports = packager.import(*packages, env: options[:env], from: options[:from])
+ with_import_response(packages, env: options[:env], from: options[:from]) do |imports, _integrity_hashes|
imports.each do |package, url|
if packager.packaged?(package)
puts %(Unpinning and removing "#{package}")
packager.remove(package)
end
end
- else
- puts "Couldn't find any packages in #{packages.inspect} on #{options[:from]}"
end
end
desc "pristine", "Redownload all pinned packages"
option :env, type: :string, aliases: :e, default: "production"
option :from, type: :string, aliases: :f, default: "jspm"
+ option :integrity, type: :boolean, aliases: :i, default: true, desc: "Include integrity hash from JSPM"
def pristine
- packages = npm.packages_with_versions.map do |p, v|
- v.blank? ? p : [p, v].join("@")
- end
+ packages = prepare_packages_with_versions
- if imports = packager.import(*packages, env: options[:env], from: options[:from])
- imports.each do |package, url|
+ with_import_response(packages, env: options[:env], from: options[:from], integrity: options[:integrity]) do |imports, integrity_hashes|
+ process_imports(imports, integrity_hashes) do |package, url, integrity_hash|
puts %(Downloading "#{package}" to #{packager.vendor_path}/#{package}.js from #{url})
+
packager.download(package, url)
+
+ log_integrity_usage(integrity_hash)
end
- else
- puts "Couldn't find any packages in #{packages.inspect} on #{options[:from]}"
end
end
@@ -122,6 +118,33 @@ def packages
puts npm.packages_with_versions.map { |x| x.join(' ') }
end
+ desc "integrity [*PACKAGES]", "Download and add integrity hashes for packages"
+ option :env, type: :string, aliases: :e, default: "production"
+ option :from, type: :string, aliases: :f, default: "jspm"
+ option :update, type: :boolean, aliases: :u, default: false, desc: "Update importmap.rb with integrity hashes"
+ def integrity(*packages)
+ packages = prepare_packages_with_versions(packages)
+
+ with_import_response(packages, env: options[:env], from: options[:from], integrity: true) do |imports, integrity_hashes|
+ process_imports(imports, integrity_hashes) do |package, url, integrity_hash|
+ puts %(Getting integrity for "#{package}" from #{url})
+
+ if integrity_hash
+ puts %( #{package}: #{integrity_hash})
+
+ if options[:update]
+ pin_with_integrity = packager.pin_for(package, url, integrity: integrity_hash)
+
+ update_importmap_with_pin(package, pin_with_integrity)
+ puts %( Updated importmap.rb with integrity for "#{package}")
+ end
+ else
+ puts %( No integrity hash available for "#{package}")
+ end
+ end
+ end
+ end
+
private
def packager
@packager ||= Importmap::Packager.new
@@ -131,6 +154,22 @@ def npm
@npm ||= Importmap::Npm.new
end
+ def update_importmap_with_pin(package, pin)
+ if packager.packaged?(package)
+ gsub_file("config/importmap.rb", /^pin "#{package}".*$/, pin, verbose: false)
+ else
+ append_to_file("config/importmap.rb", "#{pin}\n", verbose: false)
+ end
+ end
+
+ def log_integrity_usage(integrity_hash)
+ puts %( Using integrity: #{integrity_hash}) if integrity_hash
+ end
+
+ def handle_package_not_found(packages, from)
+ puts "Couldn't find any packages in #{packages.inspect} on #{from}"
+ end
+
def remove_line_from_file(path, pattern)
path = File.expand_path(path, destination_root)
@@ -155,6 +194,33 @@ def puts_table(array)
puts divider if row_number == 0
end
end
+
+ def prepare_packages_with_versions(packages = [])
+ if packages.empty?
+ npm.packages_with_versions.map do |p, v|
+ v.blank? ? p : [p, v].join("@")
+ end
+ else
+ packages
+ end
+ end
+
+ def process_imports(imports, integrity_hashes, &block)
+ imports.each do |package, url|
+ integrity_hash = integrity_hashes[url]
+ block.call(package, url, integrity_hash)
+ end
+ end
+
+ def with_import_response(packages, **options)
+ response = packager.import(*packages, **options)
+
+ if response
+ yield response[:imports], response[:integrity]
+ else
+ handle_package_not_found(packages, options[:from])
+ end
+ end
end
Importmap::Commands.start(ARGV)
diff --git a/lib/importmap/map.rb b/lib/importmap/map.rb
index 5d0cd62..63fa2ef 100644
--- a/lib/importmap/map.rb
+++ b/lib/importmap/map.rb
@@ -25,14 +25,14 @@ def draw(path = nil, &block)
self
end
- def pin(name, to: nil, preload: true)
+ def pin(name, to: nil, preload: true, integrity: nil)
clear_cache
- @packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload)
+ @packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload, integrity: integrity)
end
- def pin_all_from(dir, under: nil, to: nil, preload: true)
+ def pin_all_from(dir, under: nil, to: nil, preload: true, integrity: nil)
clear_cache
- @directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload)
+ @directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload, integrity: integrity)
end
# Returns an array of all the resolved module paths of the pinned packages. The `resolver` must respond to
@@ -41,8 +41,72 @@ def pin_all_from(dir, under: nil, to: nil, preload: true)
# resolve for different asset hosts, you can pass in a custom `cache_key` to vary the cache used by this method for
# the different cases.
def preloaded_module_paths(resolver:, entry_point: "application", cache_key: :preloaded_module_paths)
+ preloaded_module_packages(resolver: resolver, entry_point: entry_point, cache_key: cache_key).keys
+ end
+
+ # Returns a hash of resolved module paths to their corresponding package objects for all pinned packages
+ # that are marked for preloading. The hash keys are the resolved asset paths, and the values are the
+ # +MappedFile+ objects containing package metadata including name, path, preload setting, and integrity.
+ #
+ # The +resolver+ must respond to +path_to_asset+, such as +ActionController::Base.helpers+ or
+ # +ApplicationController.helpers+. You'll want to use the resolver that has been configured for the
+ # +asset_host+ you want these resolved paths to use.
+ #
+ # ==== Parameters
+ #
+ # [+resolver+]
+ # An object that responds to +path_to_asset+ for resolving asset paths.
+ #
+ # [+entry_point+]
+ # The entry point name or array of entry point names to determine which packages should be preloaded.
+ # Defaults to +"application"+. Packages with +preload: true+ are always included regardless of entry point.
+ # Packages with specific entry point names (e.g., +preload: "admin"+) are only included when that entry
+ # point is specified.
+ #
+ # [+cache_key+]
+ # A custom cache key to vary the cache used by this method for different cases, such as resolving
+ # for different asset hosts. Defaults to +:preloaded_module_packages+.
+ #
+ # ==== Returns
+ #
+ # A hash where:
+ # * Keys are resolved asset paths (strings)
+ # * Values are +MappedFile+ objects with +name+, +path+, +preload+, and +integrity+ attributes
+ #
+ # Missing assets are gracefully handled and excluded from the returned hash.
+ #
+ # ==== Examples
+ #
+ # # Get all preloaded packages for the default "application" entry point
+ # packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers)
+ # # => { "/assets/application-abc123.js" => #,
+ # # "https://cdn.skypack.dev/react" => # }
+ #
+ # # Get preloaded packages for a specific entry point
+ # packages = importmap.preloaded_module_packages(resolver: helpers, entry_point: "admin")
+ #
+ # # Get preloaded packages for multiple entry points
+ # packages = importmap.preloaded_module_packages(resolver: helpers, entry_point: ["application", "admin"])
+ #
+ # # Use a custom cache key for different asset hosts
+ # packages = importmap.preloaded_module_packages(resolver: helpers, cache_key: "cdn_host")
+ def preloaded_module_packages(resolver:, entry_point: "application", cache_key: :preloaded_module_packages)
cache_as(cache_key) do
- resolve_asset_paths(expanded_preloading_packages_and_directories(entry_point:), resolver:).values
+ expanded_preloading_packages_and_directories(entry_point:).filter_map do |_, package|
+ resolved_path = resolve_asset_path(package.path, resolver: resolver)
+ next unless resolved_path
+
+ resolved_integrity = resolve_integrity_value(package.integrity, package.path, resolver: resolver)
+
+ package = MappedFile.new(
+ name: package.name,
+ path: package.path,
+ preload: package.preload,
+ integrity: resolved_integrity
+ )
+
+ [resolved_path, package]
+ end.to_h
end
end
@@ -53,7 +117,9 @@ def preloaded_module_paths(resolver:, entry_point: "application", cache_key: :pr
# `cache_key` to vary the cache used by this method for the different cases.
def to_json(resolver:, cache_key: :json)
cache_as(cache_key) do
- JSON.pretty_generate({ "imports" => resolve_asset_paths(expanded_packages_and_directories, resolver: resolver) })
+ packages = expanded_packages_and_directories
+ map = build_import_map(packages, resolver: resolver)
+ JSON.pretty_generate(map)
end
end
@@ -84,8 +150,8 @@ def cache_sweeper(watches: nil)
end
private
- MappedDir = Struct.new(:dir, :path, :under, :preload, keyword_init: true)
- MappedFile = Struct.new(:name, :path, :preload, keyword_init: true)
+ MappedDir = Struct.new(:dir, :path, :under, :preload, :integrity, keyword_init: true)
+ MappedFile = Struct.new(:name, :path, :preload, :integrity, keyword_init: true)
def cache_as(name)
if result = @cache[name.to_s]
@@ -105,19 +171,53 @@ def rescuable_asset_error?(error)
def resolve_asset_paths(paths, resolver:)
paths.transform_values do |mapping|
- begin
- resolver.path_to_asset(mapping.path)
- rescue => e
- if rescuable_asset_error?(e)
- Rails.logger.warn "Importmap skipped missing path: #{mapping.path}"
- nil
- else
- raise e
- end
- end
+ resolve_asset_path(mapping.path, resolver:)
end.compact
end
+ def resolve_asset_path(path, resolver:)
+ begin
+ resolver.path_to_asset(path)
+ rescue => e
+ if rescuable_asset_error?(e)
+ Rails.logger.warn "Importmap skipped missing path: #{path}"
+ nil
+ else
+ raise e
+ end
+ end
+ end
+
+ def build_import_map(packages, resolver:)
+ map = { "imports" => resolve_asset_paths(packages, resolver: resolver) }
+ integrity = build_integrity_hash(packages, resolver: resolver)
+ map["integrity"] = integrity unless integrity.empty?
+ map
+ end
+
+ def build_integrity_hash(packages, resolver:)
+ packages.filter_map do |name, mapping|
+ next unless mapping.integrity
+
+ resolved_path = resolve_asset_path(mapping.path, resolver: resolver)
+ next unless resolved_path
+
+ integrity_value = resolve_integrity_value(mapping.integrity, mapping.path, resolver: resolver)
+ next unless integrity_value
+
+ [resolved_path, integrity_value]
+ end.to_h
+ end
+
+ def resolve_integrity_value(integrity, path, resolver:)
+ case integrity
+ when true
+ resolver.asset_integrity(path) if resolver.respond_to?(:asset_integrity)
+ when String
+ integrity
+ end
+ end
+
def expanded_preloading_packages_and_directories(entry_point:)
expanded_packages_and_directories.select { |name, mapping| mapping.preload.in?([true, false]) ? mapping.preload : (Array(mapping.preload) & Array(entry_point)).any? }
end
@@ -134,7 +234,12 @@ def expand_directories_into(paths)
module_name = module_name_from(module_filename, mapping)
module_path = module_path_from(module_filename, mapping)
- paths[module_name] = MappedFile.new(name: module_name, path: module_path, preload: mapping.preload)
+ paths[module_name] = MappedFile.new(
+ name: module_name,
+ path: module_path,
+ preload: mapping.preload,
+ integrity: mapping.integrity
+ )
end
end
end
diff --git a/lib/importmap/packager.rb b/lib/importmap/packager.rb
index 9a8a889..98819d1 100644
--- a/lib/importmap/packager.rb
+++ b/lib/importmap/packager.rb
@@ -17,34 +17,39 @@ def initialize(importmap_path = "config/importmap.rb", vendor_path: "vendor/java
@vendor_path = Pathname.new(vendor_path)
end
- def import(*packages, env: "production", from: "jspm")
+ def import(*packages, env: "production", from: "jspm", integrity: false)
response = post_json({
"install" => Array(packages),
"flattenScope" => true,
"env" => [ "browser", "module", env ],
- "provider" => normalize_provider(from)
+ "provider" => normalize_provider(from),
+ "integrity" => integrity
})
case response.code
- when "200" then extract_parsed_imports(response)
- when "404", "401" then nil
- else handle_failure_response(response)
+ when "200"
+ extract_parsed_response(response)
+ when "404", "401"
+ nil
+ else
+ handle_failure_response(response)
end
end
- def pin_for(package, url)
- %(pin "#{package}", to: "#{url}")
+ def pin_for(package, url = nil, preloads: nil, integrity: nil)
+ to = url ? %(, to: "#{url}") : ""
+ preload_param = preload(preloads)
+ integrity_param = integrity ? %(, integrity: "#{integrity}") : ""
+
+ %(pin "#{package}") + to + preload_param + integrity_param
end
- def vendored_pin_for(package, url, preloads = nil)
+ def vendored_pin_for(package, url, preloads = nil, integrity: nil)
filename = package_filename(package)
version = extract_package_version_from(url)
+ to = "#{package}.js" != filename ? filename : nil
- if "#{package}.js" == filename
- %(pin "#{package}"#{preload(preloads)} # #{version})
- else
- %(pin "#{package}", to: "#{filename}"#{preload(preloads)} # #{version})
- end
+ pin_for(package, to, preloads: preloads, integrity: integrity) + %( # #{version})
end
def packaged?(package)
@@ -88,8 +93,15 @@ def normalize_provider(name)
name.to_s == "jspm" ? "jspm.io" : name.to_s
end
- def extract_parsed_imports(response)
- JSON.parse(response.body).dig("map", "imports")
+ def extract_parsed_response(response)
+ parsed = JSON.parse(response.body)
+ imports = parsed.dig("map", "imports")
+ integrity = parsed.dig("map", "integrity") || {}
+
+ {
+ imports: imports,
+ integrity: integrity
+ }
end
def handle_failure_response(response)
diff --git a/test/commands_test.rb b/test/commands_test.rb
index d5b3c27..410248b 100644
--- a/test/commands_test.rb
+++ b/test/commands_test.rb
@@ -8,7 +8,6 @@ class CommandsTest < ActiveSupport::TestCase
@tmpdir = Dir.mktmpdir
FileUtils.cp_r("#{__dir__}/dummy", @tmpdir)
Dir.chdir("#{@tmpdir}/dummy")
- FileUtils.cp("#{__dir__}/../lib/install/bin/importmap", "bin")
end
teardown do
@@ -16,32 +15,28 @@ class CommandsTest < ActiveSupport::TestCase
end
test "json command prints JSON with imports" do
- out, err = run_importmap_command("json")
+ out, _err = run_importmap_command("json")
+
assert_includes JSON.parse(out), "imports"
end
test "update command prints message of no outdated packages" do
out, _err = run_importmap_command("update")
+
assert_includes out, "No outdated"
end
test "update command prints confirmation of pin with outdated packages" do
- @tmpdir = Dir.mktmpdir
- FileUtils.cp_r("#{__dir__}/dummy", @tmpdir)
- Dir.chdir("#{@tmpdir}/dummy")
FileUtils.cp("#{__dir__}/fixtures/files/outdated_import_map.rb", "#{@tmpdir}/dummy/config/importmap.rb")
- FileUtils.cp("#{__dir__}/../lib/install/bin/importmap", "bin")
out, _err = run_importmap_command("update")
+
assert_includes out, "Pinning"
end
test "pristine command redownloads all pinned packages" do
- @tmpdir = Dir.mktmpdir
- FileUtils.cp_r("#{__dir__}/dummy", @tmpdir)
- Dir.chdir("#{@tmpdir}/dummy")
FileUtils.cp("#{__dir__}/fixtures/files/outdated_import_map.rb", "#{@tmpdir}/dummy/config/importmap.rb")
- FileUtils.cp("#{__dir__}/../lib/install/bin/importmap", "bin")
+
out, _err = run_importmap_command("pin", "md5@2.2.0")
assert_includes out, 'Pinning "md5" to vendor/javascript/md5.js via download from https://ga.jspm.io/npm:md5@2.2.0/md5.js'
@@ -55,6 +50,149 @@ class CommandsTest < ActiveSupport::TestCase
assert_equal original, File.read("#{@tmpdir}/dummy/vendor/javascript/md5.js")
end
+ test "pin command includes integrity by default" do
+ out, _err = run_importmap_command("pin", "md5@2.2.0")
+
+ assert_includes out, 'Pinning "md5" to vendor/javascript/md5.js via download from https://ga.jspm.io/npm:md5@2.2.0/md5.js'
+ assert_includes out, 'Using integrity:'
+
+ config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb")
+ assert_includes config_content, 'pin "md5", integrity: "sha384-'
+ end
+
+ test "pin command with --no-integrity option excludes integrity" do
+ out, _err = run_importmap_command("pin", "md5@2.2.0", "--no-integrity")
+
+ assert_includes out, 'Pinning "md5" to vendor/javascript/md5.js via download from https://ga.jspm.io/npm:md5@2.2.0/md5.js'
+ assert_not_includes out, 'Using integrity:'
+
+ config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb")
+ assert_includes config_content, 'pin "md5" # @2.2.0'
+ end
+
+ test "pristine command includes integrity by default" do
+ FileUtils.cp("#{__dir__}/fixtures/files/outdated_import_map.rb", "#{@tmpdir}/dummy/config/importmap.rb")
+
+ out, _err = run_importmap_command("pristine")
+
+ assert_includes out, 'Downloading "md5" to vendor/javascript/md5.js from https://ga.jspm.io/npm:md5@2.2.0/md5.js'
+ assert_includes out, 'Using integrity:'
+ end
+
+ test "pristine command with --no-integrity option excludes integrity" do
+ FileUtils.cp("#{__dir__}/fixtures/files/outdated_import_map.rb", "#{@tmpdir}/dummy/config/importmap.rb")
+
+ out, _err = run_importmap_command("pristine", "--no-integrity")
+
+ assert_includes out, 'Downloading "md5" to vendor/javascript/md5.js from https://ga.jspm.io/npm:md5@2.2.0/md5.js'
+ assert_not_includes out, 'Using integrity:'
+ end
+
+ test "pin command with explicit --integrity option includes integrity" do
+ out, _err = run_importmap_command("pin", "md5@2.2.0", "--integrity")
+
+ assert_includes out, 'Pinning "md5" to vendor/javascript/md5.js via download from https://ga.jspm.io/npm:md5@2.2.0/md5.js'
+ assert_includes out, 'Using integrity:'
+
+ config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb")
+ assert_includes config_content, 'integrity: "sha384-'
+ end
+
+ test "pin command with multiple packages includes integrity for all" do
+ out, _err = run_importmap_command("pin", "md5@2.2.0", "lodash@4.17.21")
+
+ assert_includes out, 'Pinning "md5"'
+ assert_includes out, 'Pinning "lodash"'
+ assert_includes out, 'Using integrity:'
+
+ config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb")
+ assert_includes config_content, 'pin "md5"'
+ assert_includes config_content, 'pin "lodash"'
+
+ md5_lines = config_content.lines.select { |line| line.include?('pin "md5"') }
+ lodash_lines = config_content.lines.select { |line| line.include?('pin "lodash"') }
+ assert md5_lines.any? { |line| line.include?('integrity:') }
+ assert lodash_lines.any? { |line| line.include?('integrity:') }
+ end
+
+ test "pin command with preload option includes integrity and preload" do
+ out, _err = run_importmap_command("pin", "md5@2.2.0", "--preload", "true")
+
+ assert_includes out, 'Pinning "md5" to vendor/javascript/md5.js via download from https://ga.jspm.io/npm:md5@2.2.0/md5.js'
+ assert_includes out, 'Using integrity:'
+
+ config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb")
+ assert_includes config_content, 'preload: true'
+ assert_includes config_content, 'integrity: "sha384-'
+ end
+
+ test "integrity command shows integrity hashes for specific packages" do
+ out, _err = run_importmap_command("integrity", "md5@2.2.0")
+
+ assert_includes out, 'Getting integrity for "md5" from https://ga.jspm.io/npm:md5@2.2.0/md5.js'
+ assert_includes out, 'md5: sha384-'
+ end
+
+ test "integrity command with --update option updates importmap.rb" do
+ config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb")
+ assert_includes config_content, 'pin "md5", to: "https://cdn.skypack.dev/md5", preload: true'
+
+ out, _err = run_importmap_command("integrity", "md5@2.2.0", "--update")
+
+ assert_includes out, 'Getting integrity for "md5" from https://ga.jspm.io/npm:md5@2.2.0/md5.js'
+ assert_includes out, 'md5: sha384-'
+ assert_includes out, 'Updated importmap.rb with integrity for "md5"'
+
+ config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb")
+ assert_includes config_content, 'pin "md5", to: "https://ga.jspm.io/npm:md5@2.2.0/md5.js", integrity: "sha384-'
+ end
+
+ test "integrity command with multiple packages shows integrity for all" do
+ out, _err = run_importmap_command("integrity", "md5@2.2.0", "lodash@4.17.21")
+
+ assert_includes out, 'Getting integrity for "md5"'
+ assert_includes out, 'Getting integrity for "lodash"'
+ assert_includes out, 'md5: sha384-'
+ assert_includes out, 'lodash: sha384-'
+ end
+
+ test "integrity command without packages shows integrity for all remote packages" do
+ run_importmap_command("pin", "md5@2.2.0", "--no-integrity")
+
+ out, _err = run_importmap_command("integrity")
+
+ assert_includes out, 'Getting integrity for "md5"'
+ assert_includes out, 'md5: sha384-'
+ end
+
+ test "integrity command with --update updates multiple packages" do
+ run_importmap_command("pin", "md5@2.2.0", "--no-integrity")
+ run_importmap_command("pin", "lodash@4.17.21", "--no-integrity")
+
+ out, _err = run_importmap_command("integrity", "--update")
+
+ assert_includes out, 'Updated importmap.rb with integrity for "md5"'
+ assert_includes out, 'Updated importmap.rb with integrity for "lodash"'
+
+ config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb")
+ assert_includes config_content, 'pin "md5", to: "https://ga.jspm.io/npm:md5@2.2.0/md5.js", integrity: "sha384-'
+ assert_includes config_content, 'pin "lodash", to: "https://ga.jspm.io/npm:lodash@4.17.21/lodash.js", integrity: "sha384-'
+ end
+
+ test "integrity command with env option" do
+ out, _err = run_importmap_command("integrity", "md5@2.2.0", "--env", "development")
+
+ assert_includes out, 'Getting integrity for "md5"'
+ assert_includes out, 'md5: sha384-'
+ end
+
+ test "integrity command with from option" do
+ out, _err = run_importmap_command("integrity", "md5@2.2.0", "--from", "jspm")
+
+ assert_includes out, 'Getting integrity for "md5"'
+ assert_includes out, 'md5: sha384-'
+ end
+
private
def run_importmap_command(command, *args)
capture_subprocess_io { system("bin/importmap", command, *args, exception: true) }
diff --git a/test/dummy/bin/importmap b/test/dummy/bin/importmap
new file mode 100755
index 0000000..36502ab
--- /dev/null
+++ b/test/dummy/bin/importmap
@@ -0,0 +1,4 @@
+#!/usr/bin/env ruby
+
+require_relative "../config/application"
+require "importmap/commands"
diff --git a/test/dummy/config/importmap.rb b/test/dummy/config/importmap.rb
index 850ca72..2fece22 100644
--- a/test/dummy/config/importmap.rb
+++ b/test/dummy/config/importmap.rb
@@ -2,3 +2,4 @@
pin "md5", to: "https://cdn.skypack.dev/md5", preload: true
pin "not_there", to: "nowhere.js", preload: false
+pin "rich_text", preload: true, integrity: "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb"
diff --git a/test/dummy/config/initializers/assets.rb b/test/dummy/config/initializers/assets.rb
index 969a5d8..8edcc35 100644
--- a/test/dummy/config/initializers/assets.rb
+++ b/test/dummy/config/initializers/assets.rb
@@ -11,3 +11,5 @@
# application.js, application.css, and all non-JS/CSS in the app/assets
# folder are already added.
# Rails.application.config.assets.precompile += %w( admin.js admin.css )
+
+Rails.application.config.assets.integrity_hash_algorithm = "sha384"
diff --git a/test/importmap_tags_helper_test.rb b/test/importmap_tags_helper_test.rb
index 6796aa9..21a8ef9 100644
--- a/test/importmap_tags_helper_test.rb
+++ b/test/importmap_tags_helper_test.rb
@@ -20,21 +20,39 @@ def content_security_policy_nonce
end
test "javascript_inline_importmap_tag" do
- assert_match \
- %r{},
+ assert_dom_equal(
+ %(
+
+ ),
javascript_inline_importmap_tag
+ )
end
test "javascript_importmap_module_preload_tags" do
- assert_dom_equal \
- %(),
+ assert_dom_equal(
+ %(
+
+
+ ),
javascript_importmap_module_preload_tags
+ )
end
test "tags have no nonce if CSP is not configured" do
@request = FakeRequest.new
- assert_no_match /nonce/, javascript_importmap_tags("application")
+ assert_no_match(/nonce/, javascript_importmap_tags("application"))
ensure
@request = nil
end
@@ -42,9 +60,9 @@ def content_security_policy_nonce
test "tags have nonce if CSP is configured" do
@request = FakeRequest.new("iyhD0Yc0W+c=")
- assert_match /nonce="iyhD0Yc0W\+c="/, javascript_inline_importmap_tag
- assert_match /nonce="iyhD0Yc0W\+c="/, javascript_import_module_tag("application")
- assert_match /nonce="iyhD0Yc0W\+c="/, javascript_importmap_module_preload_tags
+ assert_match(/nonce="iyhD0Yc0W\+c="/, javascript_inline_importmap_tag)
+ assert_match(/nonce="iyhD0Yc0W\+c="/, javascript_import_module_tag("application"))
+ assert_match(/nonce="iyhD0Yc0W\+c="/, javascript_importmap_module_preload_tags)
ensure
@request = nil
end
diff --git a/test/importmap_test.rb b/test/importmap_test.rb
index bfb60b3..0f341d4 100644
--- a/test/importmap_test.rb
+++ b/test/importmap_test.rb
@@ -1,12 +1,13 @@
require "test_helper"
+require "minitest/mock"
class ImportmapTest < ActiveSupport::TestCase
def setup
@importmap = Importmap::Map.new.tap do |map|
map.draw do
pin "application", preload: false
- pin "editor", to: "rich_text.js", preload: false
- pin "not_there", to: "nowhere.js", preload: false
+ pin "editor", to: "rich_text.js", preload: false, integrity: "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb"
+ pin "not_there", to: "nowhere.js", preload: false, integrity: "sha384-somefakehash"
pin "md5", to: "https://cdn.skypack.dev/md5", preload: true
pin "leaflet", to: "https://cdn.skypack.dev/leaflet", preload: 'application'
pin "chartkick", to: "https://cdn.skypack.dev/chartkick", preload: ['application', 'alternate']
@@ -30,6 +31,22 @@ def setup
assert_match %r|assets/rich_text-.*\.js|, generate_importmap_json["imports"]["editor"]
end
+ test "local pin with integrity" do
+ editor_path = generate_importmap_json["imports"]["editor"]
+ assert_match %r|assets/rich_text-.*\.js|, editor_path
+ assert_equal "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb", generate_importmap_json["integrity"][editor_path]
+ assert_nil generate_importmap_json["imports"]["not_there"]
+ assert_not_includes generate_importmap_json["integrity"].values, "sha384-somefakehash"
+ end
+
+ test "integrity is not present if there is no integrity set in the map" do
+ @importmap = Importmap::Map.new.tap do |map|
+ map.pin "application", preload: false
+ end
+
+ assert_not generate_importmap_json.key?("integrity")
+ end
+
test "local pin missing is removed from generated importmap" do
assert_nil generate_importmap_json["imports"]["not_there"]
end
@@ -73,6 +90,52 @@ def setup
assert_match %r|assets/my_lib-.*\.js|, generate_importmap_json["imports"]["my_lib"]
end
+ test "importmap json includes integrity hashes from integrity: true" do
+ importmap = Importmap::Map.new.tap do |map|
+ map.pin "application", integrity: true
+ end
+
+ json = JSON.parse(importmap.to_json(resolver: ApplicationController.helpers))
+
+ assert json["integrity"], "Should include integrity section"
+
+ application_path = json["imports"]["application"]
+ assert application_path, "Should include application in imports"
+ if ENV["ASSETS_PIPELINE"] == "sprockets"
+ assert_equal "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", json["integrity"][application_path]
+ else
+ assert_equal "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb", json["integrity"][application_path]
+ end
+ end
+
+ test "integrity: true with missing asset should be gracefully handled" do
+ importmap = Importmap::Map.new.tap do |map|
+ map.pin "missing", to: "nonexistent.js", preload: true, integrity: true
+ end
+
+ json = JSON.parse(importmap.to_json(resolver: ApplicationController.helpers))
+
+ assert_empty json["imports"]
+ assert_nil json["integrity"]
+ end
+
+ test "integrity: true with resolver that doesn't have asset_integrity method returns nil" do
+ mock_resolver = Minitest::Mock.new
+
+ mock_resolver.expect(:path_to_asset, "/assets/application-abc123.js", ["application.js"])
+ mock_resolver.expect(:path_to_asset, "/assets/application-abc123.js", ["application.js"])
+
+ importmap = Importmap::Map.new.tap do |map|
+ map.pin "application", integrity: true
+ end
+
+ json = JSON.parse(importmap.to_json(resolver: mock_resolver))
+
+ assert json["imports"]["application"]
+ assert_match %r|/assets/application-.*\.js|, json["imports"]["application"]
+ assert_nil json["integrity"]
+ end
+
test 'invalid importmap file results in error' do
file = file_fixture('invalid_import_map.rb')
importmap = Importmap::Map.new
@@ -83,35 +146,35 @@ def setup
test "preloaded modules are included in preload tags when no entry_point specified" do
preloading_module_paths = @importmap.preloaded_module_paths(resolver: ApplicationController.helpers).to_s
- assert_match /md5/, preloading_module_paths
- assert_match /goodbye_controller/, preloading_module_paths
- assert_match /leaflet/, preloading_module_paths
- assert_no_match /application/, preloading_module_paths
- assert_no_match /tinymce/, preloading_module_paths
+ assert_match(/md5/, preloading_module_paths)
+ assert_match(/goodbye_controller/, preloading_module_paths)
+ assert_match(/leaflet/, preloading_module_paths)
+ assert_no_match(/application/, preloading_module_paths)
+ assert_no_match(/tinymce/, preloading_module_paths)
end
test "preloaded modules are included in preload tags based on single entry_point provided" do
preloading_module_paths = @importmap.preloaded_module_paths(resolver: ApplicationController.helpers, entry_point: "alternate").to_s
- assert_no_match /leaflet/, preloading_module_paths
- assert_match /tinymce/, preloading_module_paths
- assert_match /chartkick/, preloading_module_paths
- assert_match /md5/, preloading_module_paths
- assert_match /goodbye_controller/, preloading_module_paths
- assert_no_match /application/, preloading_module_paths
+ assert_no_match(/leaflet/, preloading_module_paths)
+ assert_match(/tinymce/, preloading_module_paths)
+ assert_match(/chartkick/, preloading_module_paths)
+ assert_match(/md5/, preloading_module_paths)
+ assert_match(/goodbye_controller/, preloading_module_paths)
+ assert_no_match(/application/, preloading_module_paths)
end
test "preloaded modules are included in preload tags based on multiple entry_points provided" do
preloading_module_paths = @importmap.preloaded_module_paths(resolver: ApplicationController.helpers, entry_point: ["application", "alternate"]).to_s
- assert_match /leaflet/, preloading_module_paths
- assert_match /tinymce/, preloading_module_paths
- assert_match /chartkick/, preloading_module_paths
- assert_match /md5/, preloading_module_paths
- assert_match /goodbye_controller/, preloading_module_paths
- assert_no_match /application/, preloading_module_paths
+ assert_match(/leaflet/, preloading_module_paths)
+ assert_match(/tinymce/, preloading_module_paths)
+ assert_match(/chartkick/, preloading_module_paths)
+ assert_match(/md5/, preloading_module_paths)
+ assert_match(/goodbye_controller/, preloading_module_paths)
+ assert_no_match(/application/, preloading_module_paths)
end
test "digest" do
- assert_match /^\w{40}$/, @importmap.digest(resolver: ApplicationController.helpers)
+ assert_match(/^\w{40}$/, @importmap.digest(resolver: ApplicationController.helpers))
end
test "separate caches" do
@@ -136,8 +199,164 @@ def setup
assert_not_equal set_two, @importmap.preloaded_module_paths(resolver: ApplicationController.helpers, cache_key: "2").to_s
end
+ test "preloaded_module_packages returns hash of resolved paths to packages when no entry_point specified" do
+ packages = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers)
+
+ md5 = packages["https://cdn.skypack.dev/md5"]
+ assert md5, "Should include md5 package"
+ assert_equal "md5", md5.name
+ assert_equal "https://cdn.skypack.dev/md5", md5.path
+ assert_equal true, md5.preload
+
+ goodbye_controller_path = packages.keys.find { |path| path.include?("goodbye_controller") }
+ assert goodbye_controller_path, "Should include goodbye_controller package"
+ assert_equal "controllers/goodbye_controller", packages[goodbye_controller_path].name
+ assert_equal true, packages[goodbye_controller_path].preload
+
+ leaflet = packages["https://cdn.skypack.dev/leaflet"]
+ assert leaflet, "Should include leaflet package"
+ assert_equal "leaflet", leaflet.name
+ assert_equal "https://cdn.skypack.dev/leaflet", leaflet.path
+ assert_equal 'application', leaflet.preload
+
+ chartkick = packages["https://cdn.skypack.dev/chartkick"]
+ assert chartkick, "Should include chartkick package"
+ assert_equal "chartkick", chartkick.name
+ assert_equal ['application', 'alternate'], chartkick.preload
+
+ application_path = packages.keys.find { |path| path.include?("application") }
+ assert_nil application_path, "Should not include application package (preload: false)"
+
+ tinymce_path = packages.keys.find { |path| path.include?("tinymce") }
+ assert_nil tinymce_path, "Should not include tinymce package (preload: 'alternate')"
+ end
+
+ test "preloaded_module_packages returns hash based on single entry_point provided" do
+ packages = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, entry_point: "alternate")
+
+ tinymce = packages["https://cdn.skypack.dev/tinymce"]
+ assert tinymce, "Should include tinymce package for alternate entry point"
+ assert_equal "tinyMCE", tinymce.name
+ assert_equal "https://cdn.skypack.dev/tinymce", tinymce.path
+ assert_equal 'alternate', tinymce.preload
+
+ # Should include packages for multiple entry points (chartkick preloads for both 'application' and 'alternate')
+ chartkick = packages["https://cdn.skypack.dev/chartkick"]
+ assert chartkick, "Should include chartkick package"
+ assert_equal "chartkick", chartkick.name
+ assert_equal ['application', 'alternate'], chartkick.preload
+
+ # Should include always-preloaded packages
+ md5 = packages["https://cdn.skypack.dev/md5"]
+ assert md5, "Should include md5 package (always preloaded)"
+
+ leaflet_path = packages.keys.find { |path| path.include?("leaflet") }
+ assert_nil leaflet_path, "Should not include leaflet package (preload: 'application' only)"
+ end
+
+ test "preloaded_module_packages returns hash based on multiple entry_points provided" do
+ packages = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, entry_point: ["application", "alternate"])
+
+ leaflet = packages["https://cdn.skypack.dev/leaflet"]
+ assert leaflet, "Should include leaflet package for application entry point"
+
+ # Should include packages for 'alternate' entry point
+ tinymce = packages["https://cdn.skypack.dev/tinymce"]
+ assert tinymce, "Should include tinymce package for alternate entry point"
+
+ # Should include packages for multiple entry points
+ chartkick = packages["https://cdn.skypack.dev/chartkick"]
+ assert chartkick, "Should include chartkick package for both entry points"
+
+ # Should include always-preloaded packages
+ md5 = packages["https://cdn.skypack.dev/md5"]
+ assert md5, "Should include md5 package (always preloaded)"
+
+ application_path = packages.keys.find { |path| path.include?("application") }
+ assert_nil application_path, "Should not include application package (preload: false)"
+ end
+
+ test "preloaded_module_packages includes package integrity when present" do
+ # Create a new importmap with a preloaded package that has integrity
+ importmap = Importmap::Map.new.tap do |map|
+ map.pin "editor", to: "rich_text.js", preload: true, integrity: "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb"
+ end
+
+ packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers)
+
+ editor_path = packages.keys.find { |path| path.include?("rich_text") }
+ assert editor_path, "Should include editor package"
+ assert_equal "editor", packages[editor_path].name
+ assert_equal "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb", packages[editor_path].integrity
+ end
+
+ test "pin with integrity: true should calculate integrity dynamically" do
+ importmap = Importmap::Map.new.tap do |map|
+ map.pin "editor", to: "rich_text.js", preload: true, integrity: "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb"
+ end
+
+ packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers)
+
+ editor_path = packages.keys.find { |path| path.include?("rich_text") }
+ assert editor_path, "Should include editor package"
+ assert_equal "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb", packages[editor_path].integrity
+ end
+
+ test "preloaded_module_packages uses custom cache_key" do
+ set_one = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "1").to_s
+
+ ActionController::Base.asset_host = "http://assets.example.com"
+
+ set_two = @importmap.preloaded_module_packages(resolver: ActionController::Base.helpers, cache_key: "2").to_s
+
+ assert_not_equal set_one, set_two
+ ensure
+ ActionController::Base.asset_host = nil
+ end
+
+ test "preloaded_module_packages caches reset" do
+ set_one = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "1").to_s
+ set_two = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "2").to_s
+
+ @importmap.pin "something", to: "https://cdn.example.com/somewhere.js", preload: true
+
+ assert_not_equal set_one, @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "1").to_s
+ assert_not_equal set_two, @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "2").to_s
+ end
+
+ test "preloaded_module_packages handles missing assets gracefully" do
+ importmap = Importmap::Map.new.tap do |map|
+ map.pin "existing", to: "application.js", preload: true
+ map.pin "missing", to: "nonexistent.js", preload: true
+ end
+
+ packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers)
+
+ assert_equal 1, packages.size
+
+ existing_path = packages.keys.find { |path| path&.include?("application") }
+ assert existing_path, "Should include existing asset"
+ end
+
+ test "pin_all_from with integrity: true should calculate integrity dynamically" do
+ importmap = Importmap::Map.new.tap do |map|
+ map.pin_all_from "app/javascript/controllers", under: "controllers", integrity: true
+ end
+
+ packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers)
+
+ controller_path = packages.keys.find { |path| path.include?("goodbye_controller") }
+ assert controller_path, "Should include goodbye_controller package"
+ if ENV["ASSETS_PIPELINE"] == "sprockets"
+ assert_equal "sha256-6yWqFiaT8vQURc/OiKuIrEv9e/y4DMV/7nh7s5o3svA=", packages[controller_path].integrity
+ else
+ assert_equal "sha384-k7HGo2DomvN21em+AypqCekIFE3quejFnjQp3NtEIMyvFNpIdKThZhxr48anSNmP", packages[controller_path].integrity
+ end
+ assert_not_includes packages.map { |_, v| v.integrity }, nil
+ end
+
private
def generate_importmap_json
- JSON.parse @importmap.to_json(resolver: ApplicationController.helpers)
+ @generate_importmap_json ||= JSON.parse @importmap.to_json(resolver: ApplicationController.helpers)
end
end
diff --git a/test/packager_integration_test.rb b/test/packager_integration_test.rb
index 3600065..148a65e 100644
--- a/test/packager_integration_test.rb
+++ b/test/packager_integration_test.rb
@@ -5,7 +5,8 @@ class Importmap::PackagerIntegrationTest < ActiveSupport::TestCase
setup { @packager = Importmap::Packager.new(Rails.root.join("config/importmap.rb")) }
test "successful import against live service" do
- assert_equal "https://ga.jspm.io/npm:react@17.0.2/index.js", @packager.import("react@17.0.2")["react"]
+ result = @packager.import("react@17.0.2")
+ assert_equal "https://ga.jspm.io/npm:react@17.0.2/index.js", result[:imports]["react"]
end
test "missing import against live service" do
@@ -40,7 +41,6 @@ class Importmap::PackagerIntegrationTest < ActiveSupport::TestCase
@packager.download("react", package_url)
assert File.exist?(vendored_package_file)
assert_equal "// react@17.0.2 downloaded from #{package_url}", File.readlines(vendored_package_file).first.strip
-
@packager.remove("react")
assert_not File.exist?(Pathname.new(vendor_dir).join("react.js"))
end
diff --git a/test/packager_test.rb b/test/packager_test.rb
index a4ba75c..ad36705 100644
--- a/test/packager_test.rb
+++ b/test/packager_test.rb
@@ -22,7 +22,9 @@ def code() "200" end
end.new
@packager.stub(:post_json, response) do
- assert_equal(response.imports, @packager.import("react@17.0.2"))
+ result = @packager.import("react@17.0.2")
+ assert_equal response.imports, result[:imports]
+ assert_equal({}, result[:integrity])
end
end
@@ -49,6 +51,51 @@ def code() "200" end
test "pin_for" do
assert_equal %(pin "react", to: "https://cdn/react"), @packager.pin_for("react", "https://cdn/react")
+ assert_equal(
+ %(pin "react", to: "https://cdn/react", integrity: "sha384-abcdef"),
+ @packager.pin_for("react", "https://cdn/react", integrity: "sha384-abcdef")
+ )
+ assert_equal(
+ %(pin "react", to: "https://cdn/react"),
+ @packager.pin_for("react", "https://cdn/react", integrity: nil)
+ )
+ assert_equal(
+ %(pin "react", to: "https://cdn/react", preload: true),
+ @packager.pin_for("react", "https://cdn/react", preloads: ["true"])
+ )
+ assert_equal(
+ %(pin "react", to: "https://cdn/react", preload: false),
+ @packager.pin_for("react", "https://cdn/react", preloads: ["false"])
+ )
+ assert_equal(
+ %(pin "react", to: "https://cdn/react", preload: "foo"),
+ @packager.pin_for("react", "https://cdn/react", preloads: ["foo"])
+ )
+ assert_equal(
+ %(pin "react", to: "https://cdn/react", preload: ["foo", "bar"]),
+ @packager.pin_for("react", "https://cdn/react", preloads: ["foo", "bar"])
+ )
+ assert_equal(
+ %(pin "react", to: "https://cdn/react", preload: true, integrity: "sha384-abcdef"),
+ @packager.pin_for("react", "https://cdn/react", preloads: ["true"], integrity: "sha384-abcdef")
+ )
+ assert_equal(
+ %(pin "react", to: "https://cdn/react", preload: false, integrity: "sha384-abcdef"),
+ @packager.pin_for("react", "https://cdn/react", preloads: ["false"], integrity: "sha384-abcdef")
+ )
+ assert_equal(
+ %(pin "react", to: "https://cdn/react", preload: "foo", integrity: "sha384-abcdef"),
+ @packager.pin_for("react", "https://cdn/react", preloads: ["foo"], integrity: "sha384-abcdef")
+ )
+ assert_equal(
+ %(pin "react", to: "https://cdn/react", preload: ["foo", "bar"], integrity: "sha384-abcdef"),
+ @packager.pin_for("react", "https://cdn/react", preloads: ["foo", "bar"], integrity: "sha384-abcdef")
+ )
+
+ assert_equal %(pin "react"), @packager.pin_for("react")
+ assert_equal %(pin "react", preload: true), @packager.pin_for("react", preloads: ["true"])
+ assert_equal %(pin "react", integrity: "sha384-abcdef"), @packager.pin_for("react", integrity: "sha384-abcdef")
+ assert_equal %(pin "react", preload: true, integrity: "sha384-abcdef"), @packager.pin_for("react", preloads: ["true"], integrity: "sha384-abcdef")
end
test "vendored_pin_for" do
@@ -58,5 +105,71 @@ def code() "200" end
assert_equal %(pin "react", preload: false # @17.0.2), @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["false"])
assert_equal %(pin "react", preload: "foo" # @17.0.2), @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["foo"])
assert_equal %(pin "react", preload: ["foo", "bar"] # @17.0.2), @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["foo", "bar"])
+ assert_equal(
+ %(pin "react", integrity: "sha384-abcdef" # @17.0.2),
+ @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", nil, integrity: "sha384-abcdef")
+ )
+ assert_equal(
+ %(pin "javascript/react", to: "javascript--react.js", integrity: "sha384-abcdef" # @17.0.2),
+ @packager.vendored_pin_for("javascript/react", "https://cdn/react@17.0.2", nil, integrity: "sha384-abcdef")
+ )
+ assert_equal(
+ %(pin "react", preload: true, integrity: "sha384-abcdef" # @17.0.2),
+ @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["true"], integrity: "sha384-abcdef")
+ )
+ assert_equal(
+ %(pin "react", preload: false, integrity: "sha384-abcdef" # @17.0.2),
+ @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["false"], integrity: "sha384-abcdef")
+ )
+ assert_equal(
+ %(pin "react", preload: "foo", integrity: "sha384-abcdef" # @17.0.2),
+ @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["foo"], integrity: "sha384-abcdef")
+ )
+ assert_equal(
+ %(pin "react", preload: ["foo", "bar"], integrity: "sha384-abcdef" # @17.0.2),
+ @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["foo", "bar"], integrity: "sha384-abcdef")
+ )
+ assert_equal(
+ %(pin "react" # @17.0.2),
+ @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", nil, integrity: nil)
+ )
+ end
+
+ test "import with integrity parameter" do
+ response = Class.new do
+ def body
+ {
+ "map" => {
+ "imports" => imports,
+ "integrity" => integrity_map
+ }
+ }.to_json
+ end
+
+ def imports
+ {
+ "react" => "https://ga.jspm.io/npm:react@17.0.2/index.js",
+ "object-assign" => "https://ga.jspm.io/npm:object-assign@4.1.1/index.js"
+ }
+ end
+
+ def integrity_map
+ {
+ "https://ga.jspm.io/npm:react@17.0.2/index.js" => "sha384-abcdef1234567890",
+ "https://ga.jspm.io/npm:object-assign@4.1.1/index.js" => "sha384-1234567890abcdef"
+ }
+ end
+
+ def code() "200" end
+ end.new
+
+ @packager.stub(:post_json, response) do
+ result = @packager.import("react@17.0.2", integrity: true)
+ assert_equal response.imports, result[:imports]
+ assert_equal({
+ "https://ga.jspm.io/npm:react@17.0.2/index.js" => "sha384-abcdef1234567890",
+ "https://ga.jspm.io/npm:object-assign@4.1.1/index.js" => "sha384-1234567890abcdef"
+ }, result[:integrity])
+ end
end
end
diff --git a/test/reloader_test.rb b/test/reloader_test.rb
index e366b92..60de5f4 100644
--- a/test/reloader_test.rb
+++ b/test/reloader_test.rb
@@ -16,7 +16,7 @@ class ReloaderTest < ActiveSupport::TestCase
Rails.application.importmap = Importmap::Map.new.draw { pin "md5", to: "https://cdn.skypack.dev/md5" }
assert_not_predicate @reloader, :updated?
- assert_changes -> { Rails.application.importmap.packages.keys }, from: %w[ md5 ], to: %w[ md5 not_there ] do
+ assert_changes -> { Rails.application.importmap.packages.keys }, from: %w[ md5 ], to: %w[ md5 not_there rich_text ] do
touch_config
assert @reloader.execute_if_updated
end