importmap-rails 2.1.0 → 2.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 24bbd66810fcdeffe321841aa86a4308b0fa76749e9ec20105f8d74475634aa7
4
- data.tar.gz: ea19b79e7701a7aafd6fe546edc88880aa5e68858999959d8e52005ce0be5a34
3
+ metadata.gz: 91837e96107f59efa73a314dec3955a488f9f40826c27e116da43c5d9ef0a44a
4
+ data.tar.gz: ce9d44539cd6bb86b1e027c9c714dc8274b4a7b918ee0a67493b79b9eb58103f
5
5
  SHA512:
6
- metadata.gz: 741aab3cb6747eba7daf39dd5d311c126dfccfb55132c68d1e1cdaafe2b84a9e2596ea4746446cf6f1702d851a109cae3413b97a233889cd8855e347098ee592
7
- data.tar.gz: 3b9ed94a737134bbfcd8be54f95fa5094539b2b6d56b7365362bdf7ac961bb1e727cef396eca0001629d45e2c50578c3af819559a521829f41b4e875733d3e80
6
+ metadata.gz: 95a4d846c8a808b8037e2425ad307e1cecd736051053e86f5c9739f9118375d81e0e967f64be3f6cee916d0ce7ca763fd7b236684f9b0889679b62f5718643a0
7
+ data.tar.gz: 5e20ed5b2d79945a603db1cf6e0e5d694a8a7ad7b96f5bb13e6e1083225034fcbf7ba2019acf397b004345bd4d54827895b9e4ffd5c831eb1fdfc2c051dcb063
data/README.md CHANGED
@@ -44,7 +44,7 @@ import React from "./node_modules/react"
44
44
  import React from "https://ga.jspm.io/npm:react@17.0.1/index.js"
45
45
  ```
46
46
 
47
- Importmap-rails provides a clean API for mapping "bare module specifiers" like `"react"`
47
+ Importmap-rails provides a clean API for mapping "bare module specifiers" like `"react"`
48
48
  to 1 of the 3 viable ways of loading ES Module javascript packages.
49
49
 
50
50
  For example:
@@ -54,11 +54,11 @@ For example:
54
54
  pin "react", to: "https://ga.jspm.io/npm:react@17.0.2/index.js"
55
55
  ```
56
56
 
57
- means "everytime you see `import React from "react"`
57
+ means "every time you see `import React from "react"`
58
58
  change it to `import React from "https://ga.jspm.io/npm:react@17.0.2/index.js"`"
59
59
 
60
60
  ```js
61
- import React from "react"
61
+ import React from "react"
62
62
  // => import React from "https://ga.jspm.io/npm:react@17.0.2/index.js"
63
63
  ```
64
64
 
@@ -79,10 +79,16 @@ If you want to import local js module files from `app/javascript/src` or other s
79
79
  ```rb
80
80
  # config/importmap.rb
81
81
  pin_all_from 'app/javascript/src', under: 'src', to: 'src'
82
+
83
+ # With automatic integrity calculation for enhanced security
84
+ enable_integrity!
85
+ pin_all_from 'app/javascript/controllers', under: 'controllers', integrity: true
82
86
  ```
83
87
 
84
88
  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.
85
89
 
90
+ The `enable_integrity!` call enables integrity calculation globally, and `integrity: true` automatically calculates integrity hashes for all files in the directory, providing security benefits without manual hash management.
91
+
86
92
  Allows you to:
87
93
 
88
94
  ```js
@@ -97,19 +103,29 @@ Note: Sprockets used to serve assets (albeit without filename digests) it couldn
97
103
 
98
104
  Importmap for Rails downloads and vendors your npm package dependencies via JavaScript CDNs that provide pre-compiled distribution versions.
99
105
 
100
- You can use the `./bin/importmap` command that's added as part of the install to pin, unpin, or update npm packages in your import map. This command uses an API from [JSPM.org](https://jspm.org) to resolve your package dependencies efficiently, and then add the pins to your `config/importmap.rb` file. It can resolve these dependencies from JSPM itself, but also from other CDNs, like [unpkg.com](https://unpkg.com) and [jsdelivr.com](https://www.jsdelivr.com).
106
+ You can use the `./bin/importmap` command that's added as part of the install to pin, unpin, or update npm packages in your import map. By default this command uses an API from [JSPM.org](https://jspm.org) to resolve your package dependencies efficiently, and then add the pins to your `config/importmap.rb` file.
101
107
 
102
108
  ```bash
103
109
  ./bin/importmap pin react
104
- Pinning "react" to vendor/react.js via download from https://ga.jspm.io/npm:react@17.0.2/index.js
105
- Pinning "object-assign" to vendor/object-assign.js via download from https://ga.jspm.io/npm:object-assign@4.1.1/index.js
110
+ Pinning "react" to vendor/javascript/react.js via download from https://ga.jspm.io/npm:react@19.1.0/index.js
106
111
  ```
107
112
 
108
- This will produce pins in your `config/importmap.rb` like so:
113
+ This will produce a pin in your `config/importmap.rb` like so:
109
114
 
110
115
  ```ruby
111
- pin "react" # https://ga.jspm.io/npm:react@17.0.2/index.js
112
- pin "object-assign" # https://ga.jspm.io/npm:object-assign@4.1.1/index.js
116
+ pin "react" # @19.1.0
117
+ ```
118
+
119
+ Other CDNs like [unpkg.com](https://unpkg.com) and [jsdelivr.com](https://www.jsdelivr.com) can be specified with `--from`:
120
+
121
+ ```bash
122
+ ./bin/importmap pin react --from unpkg
123
+ Pinning "react" to vendor/javascript/react.js via download from https://unpkg.com/react@19.1.0/index.js
124
+ ```
125
+
126
+ ```bash
127
+ ./bin/importmap pin react --from jsdelivr
128
+ Pinning "react" to vendor/javascript/react.js via download from https://cdn.jsdelivr.net/npm/react@19.1.0/index.js
113
129
  ```
114
130
 
115
131
  The packages are downloaded to `vendor/javascript`, which you can check into your source control, and they'll be available through your application's own asset pipeline serving.
@@ -119,9 +135,92 @@ If you later wish to remove a downloaded pin:
119
135
  ```bash
120
136
  ./bin/importmap unpin react
121
137
  Unpinning and removing "react"
122
- Unpinning and removing "object-assign"
123
138
  ```
124
139
 
140
+ ## Subresource Integrity (SRI)
141
+
142
+ For enhanced security, importmap-rails supports [Subresource Integrity (SRI)](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) hashes for packages loaded from external CDNs.
143
+
144
+ ### Automatic integrity for local assets
145
+
146
+ To enable automatic integrity calculation for local assets served by the Rails asset pipeline, you must first call `enable_integrity!` in your importmap configuration:
147
+
148
+ ```ruby
149
+ # config/importmap.rb
150
+
151
+ # Enable integrity calculation globally
152
+ enable_integrity!
153
+
154
+ # With integrity enabled, these will auto-calculate integrity hashes
155
+ pin "application" # Auto-calculated integrity
156
+ pin "admin", to: "admin.js" # Auto-calculated integrity
157
+ pin_all_from "app/javascript/controllers", under: "controllers" # Auto-calculated integrity
158
+
159
+ # Mixed usage - explicitly controlling integrity
160
+ pin "cdn_package", integrity: "sha384-abc123..." # Pre-calculated hash
161
+ pin "no_integrity_package", integrity: false # Explicitly disable integrity
162
+ pin "nil_integrity_package", integrity: nil # Explicitly disable integrity
163
+ ```
164
+
165
+ This is particularly useful for:
166
+ * **Local JavaScript files** managed by your Rails asset pipeline
167
+ * **Bulk operations** with `pin_all_from` where calculating hashes manually would be tedious
168
+ * **Development workflow** where asset contents change frequently
169
+
170
+ **Note:** Integrity calculation is opt-in and must be enabled with `enable_integrity!`. This behavior can be further controlled by setting `integrity: false` or `integrity: nil` on individual pins.
171
+
172
+ **Important for Propshaft users:** SRI support requires Propshaft 1.2+ and you must configure the integrity hash algorithm in your application:
173
+
174
+ ```ruby
175
+ # config/application.rb or config/environments/*.rb
176
+ config.assets.integrity_hash_algorithm = 'sha256' # or 'sha384', 'sha512'
177
+ ```
178
+
179
+ Without this configuration, integrity will be disabled by default when using Propshaft. Sprockets includes integrity support out of the box.
180
+
181
+ **Example output with `enable_integrity!` and `integrity: true`:**
182
+ ```json
183
+ {
184
+ "imports": {
185
+ "application": "/assets/application-abc123.js",
186
+ "controllers/hello_controller": "/assets/controllers/hello_controller-def456.js"
187
+ },
188
+ "integrity": {
189
+ "/assets/application-abc123.js": "sha256-xyz789...",
190
+ "/assets/controllers/hello_controller-def456.js": "sha256-uvw012..."
191
+ }
192
+ }
193
+ ```
194
+
195
+ ### How integrity works
196
+
197
+ The integrity hashes are automatically included in your import map and module preload tags:
198
+
199
+ **Import map JSON:**
200
+ ```json
201
+ {
202
+ "imports": {
203
+ "lodash": "https://ga.jspm.io/npm:lodash@4.17.21/lodash.js",
204
+ "application": "/assets/application-abc123.js",
205
+ "controllers/hello_controller": "/assets/controllers/hello_controller-def456.js"
206
+ },
207
+ "integrity": {
208
+ "https://ga.jspm.io/npm:lodash@4.17.21/lodash.js": "sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF"
209
+ "/assets/application-abc123.js": "sha256-xyz789...",
210
+ "/assets/controllers/hello_controller-def456.js": "sha256-uvw012..."
211
+ }
212
+ }
213
+ ```
214
+
215
+ **Module preload tags:**
216
+ ```html
217
+ <link rel="modulepreload" href="https://ga.jspm.io/npm:lodash@4.17.21/lodash.js" integrity="sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF">
218
+ <link rel="modulepreload" href="/assets/application-abc123.js" integrity="sha256-xyz789...">
219
+ <link rel="modulepreload" href="/assets/controllers/hello_controller-def456.js" integrity="sha256-uvw012...">
220
+ ```
221
+
222
+ Modern browsers will automatically validate these integrity hashes when loading the JavaScript modules, ensuring the files haven't been modified.
223
+
125
224
  ## Preloading pinned modules
126
225
 
127
226
  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.
@@ -208,7 +307,7 @@ Pin your js file:
208
307
  pin "checkout", preload: false
209
308
  ```
210
309
 
211
- 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.
310
+ 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.
212
311
 
213
312
  ```erb
214
313
  <% content_for :head do %>
@@ -25,13 +25,23 @@ module Importmap::ImportmapTagsHelper
25
25
  # (defaults to Rails.application.importmap), such that they'll be fetched
26
26
  # in advance by browsers supporting this link type (https://caniuse.com/?search=modulepreload).
27
27
  def javascript_importmap_module_preload_tags(importmap = Rails.application.importmap, entry_point: "application")
28
- javascript_module_preload_tag(*importmap.preloaded_module_paths(resolver: self, entry_point:, cache_key: entry_point))
28
+ packages = importmap.preloaded_module_packages(resolver: self, entry_point:, cache_key: entry_point)
29
+
30
+ _generate_preload_tags(packages) { |path, package| [path, { integrity: package.integrity }] }
29
31
  end
30
32
 
31
33
  # Link tag(s) for preloading the JavaScript module residing in `*paths`. Will return one link tag per path element.
32
34
  def javascript_module_preload_tag(*paths)
33
- safe_join(Array(paths).collect { |path|
34
- tag.link rel: "modulepreload", href: path, nonce: request&.content_security_policy_nonce
35
- }, "\n")
35
+ _generate_preload_tags(paths) { |path| [path, {}] }
36
36
  end
37
+
38
+ private
39
+ def _generate_preload_tags(items)
40
+ content_security_policy_nonce = request&.content_security_policy_nonce
41
+
42
+ safe_join(Array(items).collect { |item|
43
+ path, options = yield(item)
44
+ tag.link rel: "modulepreload", href: path, nonce: content_security_policy_nonce, **options
45
+ }, "\n")
46
+ end
37
47
  end
@@ -12,21 +12,10 @@ class Importmap::Commands < Thor
12
12
  desc "pin [*PACKAGES]", "Pin new packages"
13
13
  option :env, type: :string, aliases: :e, default: "production"
14
14
  option :from, type: :string, aliases: :f, default: "jspm"
15
+ option :preload, type: :string, repeatable: true, desc: "Can be used multiple times"
15
16
  def pin(*packages)
16
- if imports = packager.import(*packages, env: options[:env], from: options[:from])
17
- imports.each do |package, url|
18
- puts %(Pinning "#{package}" to #{packager.vendor_path}/#{package}.js via download from #{url})
19
- packager.download(package, url)
20
- pin = packager.vendored_pin_for(package, url)
21
-
22
- if packager.packaged?(package)
23
- gsub_file("config/importmap.rb", /^pin "#{package}".*$/, pin, verbose: false)
24
- else
25
- append_to_file("config/importmap.rb", "#{pin}\n", verbose: false)
26
- end
27
- end
28
- else
29
- puts "Couldn't find any packages in #{packages.inspect} on #{options[:from]}"
17
+ for_each_import(packages, env: options[:env], from: options[:from]) do |package, url|
18
+ pin_package(package, url, options[:preload])
30
19
  end
31
20
  end
32
21
 
@@ -34,15 +23,11 @@ class Importmap::Commands < Thor
34
23
  option :env, type: :string, aliases: :e, default: "production"
35
24
  option :from, type: :string, aliases: :f, default: "jspm"
36
25
  def unpin(*packages)
37
- if imports = packager.import(*packages, env: options[:env], from: options[:from])
38
- imports.each do |package, url|
39
- if packager.packaged?(package)
40
- puts %(Unpinning and removing "#{package}")
41
- packager.remove(package)
42
- end
26
+ for_each_import(packages, env: options[:env], from: options[:from]) do |package, url|
27
+ if packager.packaged?(package)
28
+ puts %(Unpinning and removing "#{package}")
29
+ packager.remove(package)
43
30
  end
44
- else
45
- puts "Couldn't find any packages in #{packages.inspect} on #{options[:from]}"
46
31
  end
47
32
  end
48
33
 
@@ -50,17 +35,12 @@ class Importmap::Commands < Thor
50
35
  option :env, type: :string, aliases: :e, default: "production"
51
36
  option :from, type: :string, aliases: :f, default: "jspm"
52
37
  def pristine
53
- packages = npm.packages_with_versions.map do |p, v|
54
- v.blank? ? p : [p, v].join("@")
55
- end
38
+ packages = prepare_packages_with_versions
56
39
 
57
- if imports = packager.import(*packages, env: options[:env], from: options[:from])
58
- imports.each do |package, url|
59
- puts %(Downloading "#{package}" to #{packager.vendor_path}/#{package}.js from #{url})
60
- packager.download(package, url)
61
- end
62
- else
63
- puts "Couldn't find any packages in #{packages.inspect} on #{options[:from]}"
40
+ for_each_import(packages, env: options[:env], from: options[:from]) do |package, url|
41
+ puts %(Downloading "#{package}" to #{packager.vendor_path}/#{package}.js from #{url})
42
+
43
+ packager.download(package, url)
64
44
  end
65
45
  end
66
46
 
@@ -110,7 +90,14 @@ class Importmap::Commands < Thor
110
90
  desc "update", "Update outdated package pins"
111
91
  def update
112
92
  if (outdated_packages = npm.outdated_packages).any?
113
- pin(*outdated_packages.map(&:name))
93
+ package_names = outdated_packages.map(&:name)
94
+ packages_with_options = packager.extract_existing_pin_options(package_names)
95
+
96
+ for_each_import(package_names, env: "production", from: "jspm") do |package, url|
97
+ options = packages_with_options[package] || {}
98
+
99
+ pin_package(package, url, options[:preload])
100
+ end
114
101
  else
115
102
  puts "No outdated packages found"
116
103
  end
@@ -130,6 +117,30 @@ class Importmap::Commands < Thor
130
117
  @npm ||= Importmap::Npm.new
131
118
  end
132
119
 
120
+ def pin_package(package, url, preload)
121
+ puts %(Pinning "#{package}" to #{packager.vendor_path}/#{package}.js via download from #{url})
122
+
123
+ packager.download(package, url)
124
+
125
+ pin = packager.vendored_pin_for(package, url, preload)
126
+
127
+ update_importmap_with_pin(package, pin)
128
+ end
129
+
130
+ def update_importmap_with_pin(package, pin)
131
+ new_pin = "#{pin}\n"
132
+
133
+ if packager.packaged?(package)
134
+ gsub_file("config/importmap.rb", Importmap::Map.pin_line_regexp_for(package), pin, verbose: false)
135
+ else
136
+ append_to_file("config/importmap.rb", new_pin, verbose: false)
137
+ end
138
+ end
139
+
140
+ def handle_package_not_found(packages, from)
141
+ puts "Couldn't find any packages in #{packages.inspect} on #{from}"
142
+ end
143
+
133
144
  def remove_line_from_file(path, pattern)
134
145
  path = File.expand_path(path, destination_root)
135
146
 
@@ -154,6 +165,26 @@ class Importmap::Commands < Thor
154
165
  puts divider if row_number == 0
155
166
  end
156
167
  end
168
+
169
+ def prepare_packages_with_versions(packages = [])
170
+ if packages.empty?
171
+ npm.packages_with_versions.map do |p, v|
172
+ v.blank? ? p : [p, v].join("@")
173
+ end
174
+ else
175
+ packages
176
+ end
177
+ end
178
+
179
+ def for_each_import(packages, **options, &block)
180
+ response = packager.import(*packages, **options)
181
+
182
+ if response
183
+ response[:imports].each(&block)
184
+ else
185
+ handle_package_not_found(packages, options[:from])
186
+ end
187
+ end
157
188
  end
158
189
 
159
190
  Importmap::Commands.start(ARGV)
data/lib/importmap/map.rb CHANGED
@@ -3,9 +3,16 @@ require "pathname"
3
3
  class Importmap::Map
4
4
  attr_reader :packages, :directories
5
5
 
6
+ PIN_REGEX = /^pin\s+["']([^"']+)["']/.freeze # :nodoc:
7
+
8
+ def self.pin_line_regexp_for(package) # :nodoc:
9
+ /^.*pin\s+["']#{Regexp.escape(package)}["'].*$/.freeze
10
+ end
11
+
6
12
  class InvalidFile < StandardError; end
7
13
 
8
14
  def initialize
15
+ @integrity = false
9
16
  @packages, @directories = {}, {}
10
17
  @cache = {}
11
18
  end
@@ -25,14 +32,51 @@ class Importmap::Map
25
32
  self
26
33
  end
27
34
 
28
- def pin(name, to: nil, preload: true)
35
+ # Enables automatic integrity hash calculation for all pinned modules.
36
+ #
37
+ # When enabled, integrity values are included in the importmap JSON for all
38
+ # pinned modules. For local assets served by the Rails asset pipeline,
39
+ # integrity hashes are automatically calculated when +integrity: true+ is
40
+ # specified. For modules with explicit integrity values, those values are
41
+ # included as provided. This provides Subresource Integrity (SRI) protection
42
+ # to ensure JavaScript modules haven't been tampered with.
43
+ #
44
+ # Clears the importmap cache when called to ensure fresh integrity hashes
45
+ # are generated.
46
+ #
47
+ # ==== Examples
48
+ #
49
+ # # config/importmap.rb
50
+ # enable_integrity!
51
+ #
52
+ # # These will now auto-calculate integrity hashes
53
+ # pin "application" # integrity: true by default
54
+ # pin "admin", to: "admin.js" # integrity: true by default
55
+ # pin_all_from "app/javascript/lib" # integrity: true by default
56
+ #
57
+ # # Manual control still works
58
+ # pin "no_integrity", integrity: false
59
+ # pin "custom_hash", integrity: "sha384-abc123..."
60
+ #
61
+ # ==== Notes
62
+ #
63
+ # * Integrity calculation is disabled by default and must be explicitly enabled
64
+ # * Requires asset pipeline support for integrity calculation (Sprockets or Propshaft 1.2+)
65
+ # * For Propshaft, you must configure +config.assets.integrity_hash_algorithm+
66
+ # * External CDN packages should provide their own integrity hashes
67
+ def enable_integrity!
29
68
  clear_cache
30
- @packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload)
69
+ @integrity = true
31
70
  end
32
71
 
33
- def pin_all_from(dir, under: nil, to: nil, preload: true)
72
+ def pin(name, to: nil, preload: true, integrity: true)
34
73
  clear_cache
35
- @directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload)
74
+ @packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload, integrity: integrity)
75
+ end
76
+
77
+ def pin_all_from(dir, under: nil, to: nil, preload: true, integrity: true)
78
+ clear_cache
79
+ @directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload, integrity: integrity)
36
80
  end
37
81
 
38
82
  # Returns an array of all the resolved module paths of the pinned packages. The `resolver` must respond to
@@ -41,8 +85,72 @@ class Importmap::Map
41
85
  # resolve for different asset hosts, you can pass in a custom `cache_key` to vary the cache used by this method for
42
86
  # the different cases.
43
87
  def preloaded_module_paths(resolver:, entry_point: "application", cache_key: :preloaded_module_paths)
88
+ preloaded_module_packages(resolver: resolver, entry_point: entry_point, cache_key: cache_key).keys
89
+ end
90
+
91
+ # Returns a hash of resolved module paths to their corresponding package objects for all pinned packages
92
+ # that are marked for preloading. The hash keys are the resolved asset paths, and the values are the
93
+ # +MappedFile+ objects containing package metadata including name, path, preload setting, and integrity.
94
+ #
95
+ # The +resolver+ must respond to +path_to_asset+, such as +ActionController::Base.helpers+ or
96
+ # +ApplicationController.helpers+. You'll want to use the resolver that has been configured for the
97
+ # +asset_host+ you want these resolved paths to use.
98
+ #
99
+ # ==== Parameters
100
+ #
101
+ # [+resolver+]
102
+ # An object that responds to +path_to_asset+ for resolving asset paths.
103
+ #
104
+ # [+entry_point+]
105
+ # The entry point name or array of entry point names to determine which packages should be preloaded.
106
+ # Defaults to +"application"+. Packages with +preload: true+ are always included regardless of entry point.
107
+ # Packages with specific entry point names (e.g., +preload: "admin"+) are only included when that entry
108
+ # point is specified.
109
+ #
110
+ # [+cache_key+]
111
+ # A custom cache key to vary the cache used by this method for different cases, such as resolving
112
+ # for different asset hosts. Defaults to +:preloaded_module_packages+.
113
+ #
114
+ # ==== Returns
115
+ #
116
+ # A hash where:
117
+ # * Keys are resolved asset paths (strings)
118
+ # * Values are +MappedFile+ objects with +name+, +path+, +preload+, and +integrity+ attributes
119
+ #
120
+ # Missing assets are gracefully handled and excluded from the returned hash.
121
+ #
122
+ # ==== Examples
123
+ #
124
+ # # Get all preloaded packages for the default "application" entry point
125
+ # packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers)
126
+ # # => { "/assets/application-abc123.js" => #<struct name="application", path="application.js", preload=true, integrity=nil>,
127
+ # # "https://cdn.skypack.dev/react" => #<struct name="react", path="https://cdn.skypack.dev/react", preload=true, integrity="sha384-..."> }
128
+ #
129
+ # # Get preloaded packages for a specific entry point
130
+ # packages = importmap.preloaded_module_packages(resolver: helpers, entry_point: "admin")
131
+ #
132
+ # # Get preloaded packages for multiple entry points
133
+ # packages = importmap.preloaded_module_packages(resolver: helpers, entry_point: ["application", "admin"])
134
+ #
135
+ # # Use a custom cache key for different asset hosts
136
+ # packages = importmap.preloaded_module_packages(resolver: helpers, cache_key: "cdn_host")
137
+ def preloaded_module_packages(resolver:, entry_point: "application", cache_key: :preloaded_module_packages)
44
138
  cache_as(cache_key) do
45
- resolve_asset_paths(expanded_preloading_packages_and_directories(entry_point:), resolver:).values
139
+ expanded_preloading_packages_and_directories(entry_point:).filter_map do |_, package|
140
+ resolved_path = resolve_asset_path(package.path, resolver: resolver)
141
+ next unless resolved_path
142
+
143
+ resolved_integrity = resolve_integrity_value(package.integrity, package.path, resolver: resolver)
144
+
145
+ package = MappedFile.new(
146
+ name: package.name,
147
+ path: package.path,
148
+ preload: package.preload,
149
+ integrity: resolved_integrity
150
+ )
151
+
152
+ [resolved_path, package]
153
+ end.to_h
46
154
  end
47
155
  end
48
156
 
@@ -53,7 +161,9 @@ class Importmap::Map
53
161
  # `cache_key` to vary the cache used by this method for the different cases.
54
162
  def to_json(resolver:, cache_key: :json)
55
163
  cache_as(cache_key) do
56
- JSON.pretty_generate({ "imports" => resolve_asset_paths(expanded_packages_and_directories, resolver: resolver) })
164
+ packages = expanded_packages_and_directories
165
+ map = build_import_map(packages, resolver: resolver)
166
+ JSON.pretty_generate(map)
57
167
  end
58
168
  end
59
169
 
@@ -84,8 +194,8 @@ class Importmap::Map
84
194
  end
85
195
 
86
196
  private
87
- MappedDir = Struct.new(:dir, :path, :under, :preload, keyword_init: true)
88
- MappedFile = Struct.new(:name, :path, :preload, keyword_init: true)
197
+ MappedDir = Struct.new(:dir, :path, :under, :preload, :integrity, keyword_init: true)
198
+ MappedFile = Struct.new(:name, :path, :preload, :integrity, keyword_init: true)
89
199
 
90
200
  def cache_as(name)
91
201
  if result = @cache[name.to_s]
@@ -105,19 +215,55 @@ class Importmap::Map
105
215
 
106
216
  def resolve_asset_paths(paths, resolver:)
107
217
  paths.transform_values do |mapping|
108
- begin
109
- resolver.path_to_asset(mapping.path)
110
- rescue => e
111
- if rescuable_asset_error?(e)
112
- Rails.logger.warn "Importmap skipped missing path: #{mapping.path}"
113
- nil
114
- else
115
- raise e
116
- end
117
- end
218
+ resolve_asset_path(mapping.path, resolver:)
118
219
  end.compact
119
220
  end
120
221
 
222
+ def resolve_asset_path(path, resolver:)
223
+ begin
224
+ resolver.path_to_asset(path)
225
+ rescue => e
226
+ if rescuable_asset_error?(e)
227
+ Rails.logger.warn "Importmap skipped missing path: #{path}"
228
+ nil
229
+ else
230
+ raise e
231
+ end
232
+ end
233
+ end
234
+
235
+ def build_import_map(packages, resolver:)
236
+ map = { "imports" => resolve_asset_paths(packages, resolver: resolver) }
237
+ integrity = build_integrity_hash(packages, resolver: resolver)
238
+ map["integrity"] = integrity unless integrity.empty?
239
+ map
240
+ end
241
+
242
+ def build_integrity_hash(packages, resolver:)
243
+ packages.filter_map do |name, mapping|
244
+ next unless mapping.integrity
245
+
246
+ resolved_path = resolve_asset_path(mapping.path, resolver: resolver)
247
+ next unless resolved_path
248
+
249
+ integrity_value = resolve_integrity_value(mapping.integrity, mapping.path, resolver: resolver)
250
+ next unless integrity_value
251
+
252
+ [resolved_path, integrity_value]
253
+ end.to_h
254
+ end
255
+
256
+ def resolve_integrity_value(integrity, path, resolver:)
257
+ return unless @integrity
258
+
259
+ case integrity
260
+ when true
261
+ resolver.asset_integrity(path) if resolver.respond_to?(:asset_integrity)
262
+ when String
263
+ integrity
264
+ end
265
+ end
266
+
121
267
  def expanded_preloading_packages_and_directories(entry_point:)
122
268
  expanded_packages_and_directories.select { |name, mapping| mapping.preload.in?([true, false]) ? mapping.preload : (Array(mapping.preload) & Array(entry_point)).any? }
123
269
  end
@@ -134,7 +280,12 @@ class Importmap::Map
134
280
  module_name = module_name_from(module_filename, mapping)
135
281
  module_path = module_path_from(module_filename, mapping)
136
282
 
137
- paths[module_name] = MappedFile.new(name: module_name, path: module_path, preload: mapping.preload)
283
+ paths[module_name] = MappedFile.new(
284
+ name: module_name,
285
+ path: module_path,
286
+ preload: mapping.preload,
287
+ integrity: mapping.integrity
288
+ )
138
289
  end
139
290
  end
140
291
  end
data/lib/importmap/npm.rb CHANGED
@@ -3,20 +3,22 @@ require "uri"
3
3
  require "json"
4
4
 
5
5
  class Importmap::Npm
6
+ PIN_REGEX = /#{Importmap::Map::PIN_REGEX}.*/.freeze # :nodoc:
7
+
6
8
  Error = Class.new(StandardError)
7
9
  HTTPError = Class.new(Error)
8
10
 
9
11
  singleton_class.attr_accessor :base_uri
10
12
  self.base_uri = URI("https://registry.npmjs.org")
11
13
 
12
- def initialize(importmap_path = "config/importmap.rb")
14
+ def initialize(importmap_path = "config/importmap.rb", vendor_path: "vendor/javascript")
13
15
  @importmap_path = Pathname.new(importmap_path)
16
+ @vendor_path = Pathname.new(vendor_path)
14
17
  end
15
18
 
16
19
  def outdated_packages
17
- packages_with_versions.each.with_object([]) do |(package, current_version), outdated_packages|
18
- outdated_package = OutdatedPackage.new(name: package,
19
- current_version: current_version)
20
+ packages_with_versions.each_with_object([]) do |(package, current_version), outdated_packages|
21
+ outdated_package = OutdatedPackage.new(name: package, current_version: current_version)
20
22
 
21
23
  if !(response = get_package(package))
22
24
  outdated_package.error = 'Response error'
@@ -36,10 +38,12 @@ class Importmap::Npm
36
38
  def vulnerable_packages
37
39
  get_audit.flat_map do |package, vulnerabilities|
38
40
  vulnerabilities.map do |vulnerability|
39
- VulnerablePackage.new(name: package,
40
- severity: vulnerability['severity'],
41
- vulnerable_versions: vulnerability['vulnerable_versions'],
42
- vulnerability: vulnerability['title'])
41
+ VulnerablePackage.new(
42
+ name: package,
43
+ severity: vulnerability['severity'],
44
+ vulnerable_versions: vulnerability['vulnerable_versions'],
45
+ vulnerability: vulnerability['title']
46
+ )
43
47
  end
44
48
  end.sort_by { |p| [p.name, p.severity] }
45
49
  end
@@ -47,17 +51,20 @@ class Importmap::Npm
47
51
  def packages_with_versions
48
52
  # We cannot use the name after "pin" because some dependencies are loaded from inside packages
49
53
  # Eg. pin "buffer", to: "https://ga.jspm.io/npm:@jspm/core@2.0.0-beta.19/nodelibs/browser/buffer.js"
54
+ with_versions = importmap.scan(/^pin .*(?<=npm:|npm\/|skypack\.dev\/|unpkg\.com\/)([^@\/]+)@(\d+\.\d+\.\d+(?:[^\/\s"']*))/) |
55
+ importmap.scan(/#{PIN_REGEX} #.*@(\d+\.\d+\.\d+(?:[^\s]*)).*$/)
56
+
57
+ vendored_packages_without_version(with_versions).each do |package, path|
58
+ $stdout.puts "Ignoring #{package} (#{path}) since no version is specified in the importmap"
59
+ end
50
60
 
51
- importmap.scan(/^pin .*(?<=npm:|npm\/|skypack\.dev\/|unpkg\.com\/)(.*)(?=@\d+\.\d+\.\d+)@(\d+\.\d+\.\d+(?:[^\/\s["']]*)).*$/) |
52
- importmap.scan(/^pin ["']([^["']]*)["'].* #.*@(\d+\.\d+\.\d+(?:[^\s]*)).*$/)
61
+ with_versions
53
62
  end
54
63
 
55
64
  private
56
65
  OutdatedPackage = Struct.new(:name, :current_version, :latest_version, :error, keyword_init: true)
57
66
  VulnerablePackage = Struct.new(:name, :severity, :vulnerable_versions, :vulnerability, keyword_init: true)
58
67
 
59
-
60
-
61
68
  def importmap
62
69
  @importmap ||= File.read(@importmap_path)
63
70
  end
@@ -76,13 +83,19 @@ class Importmap::Npm
76
83
  request = Net::HTTP::Get.new(uri)
77
84
  request["Content-Type"] = "application/json"
78
85
 
79
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http|
80
- http.request(request)
81
- }
86
+ response = begin
87
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http|
88
+ http.request(request)
89
+ }
90
+ rescue => error
91
+ raise HTTPError, "Unexpected transport error (#{error.class}: #{error.message})"
92
+ end
93
+
94
+ unless response.code.to_i < 300
95
+ raise HTTPError, "Unexpected error response #{response.code}: #{response.body}"
96
+ end
82
97
 
83
98
  response.body
84
- rescue => error
85
- raise HTTPError, "Unexpected transport error (#{error.class}: #{error.message})"
86
99
  end
87
100
 
88
101
  def find_latest_version(response)
@@ -111,6 +124,11 @@ class Importmap::Npm
111
124
  return {} if body.empty?
112
125
 
113
126
  response = post_json(uri, body)
127
+
128
+ unless response.code.to_i < 300
129
+ raise HTTPError, "Unexpected error response #{response.code}: #{response.body}"
130
+ end
131
+
114
132
  JSON.parse(response.body)
115
133
  end
116
134
 
@@ -119,4 +137,27 @@ class Importmap::Npm
119
137
  rescue => error
120
138
  raise HTTPError, "Unexpected transport error (#{error.class}: #{error.message})"
121
139
  end
140
+
141
+ def vendored_packages_without_version(packages_with_versions)
142
+ versioned_packages = packages_with_versions.map(&:first).to_set
143
+
144
+ importmap
145
+ .lines
146
+ .filter_map { |line| find_unversioned_vendored_package(line, versioned_packages) }
147
+ end
148
+
149
+ def find_unversioned_vendored_package(line, versioned_packages)
150
+ regexp = line.include?("to:")? /#{PIN_REGEX}to: ["']([^"']*)["'].*/ : PIN_REGEX
151
+ match = line.match(regexp)
152
+
153
+ return unless match
154
+
155
+ package, filename = match.captures
156
+ filename ||= "#{package}.js"
157
+
158
+ return if versioned_packages.include?(package)
159
+
160
+ path = File.join(@vendor_path, filename)
161
+ [package, path] if File.exist?(path)
162
+ end
122
163
  end
@@ -3,6 +3,9 @@ require "uri"
3
3
  require "json"
4
4
 
5
5
  class Importmap::Packager
6
+ PIN_REGEX = /#{Importmap::Map::PIN_REGEX}(.*)/.freeze # :nodoc:
7
+ PRELOAD_OPTION_REGEXP = /preload:\s*(\[[^\]]+\]|true|false|["'][^"']*["'])/.freeze # :nodoc:
8
+
6
9
  Error = Class.new(StandardError)
7
10
  HTTPError = Class.new(Error)
8
11
  ServiceError = Error.new(Error)
@@ -22,33 +25,36 @@ class Importmap::Packager
22
25
  "install" => Array(packages),
23
26
  "flattenScope" => true,
24
27
  "env" => [ "browser", "module", env ],
25
- "provider" => normalize_provider(from)
28
+ "provider" => normalize_provider(from),
26
29
  })
27
30
 
28
31
  case response.code
29
- when "200" then extract_parsed_imports(response)
30
- when "404", "401" then nil
31
- else handle_failure_response(response)
32
+ when "200"
33
+ extract_parsed_response(response)
34
+ when "404", "401"
35
+ nil
36
+ else
37
+ handle_failure_response(response)
32
38
  end
33
39
  end
34
40
 
35
- def pin_for(package, url)
36
- %(pin "#{package}", to: "#{url}")
41
+ def pin_for(package, url = nil, preloads: nil)
42
+ to = url ? %(, to: "#{url}") : ""
43
+ preload_param = preload(preloads)
44
+
45
+ %(pin "#{package}") + to + preload_param
37
46
  end
38
47
 
39
- def vendored_pin_for(package, url)
48
+ def vendored_pin_for(package, url, preloads = nil)
40
49
  filename = package_filename(package)
41
50
  version = extract_package_version_from(url)
51
+ to = "#{package}.js" != filename ? filename : nil
42
52
 
43
- if "#{package}.js" == filename
44
- %(pin "#{package}" # #{version})
45
- else
46
- %(pin "#{package}", to: "#{filename}" # #{version})
47
- end
53
+ pin_for(package, to, preloads: preloads) + %( # #{version})
48
54
  end
49
55
 
50
56
  def packaged?(package)
51
- importmap.match(/^pin ["']#{package}["'].*$/)
57
+ importmap.match(Importmap::Map.pin_line_regexp_for(package))
52
58
  end
53
59
 
54
60
  def download(package, url)
@@ -62,7 +68,65 @@ class Importmap::Packager
62
68
  remove_package_from_importmap(package)
63
69
  end
64
70
 
71
+ def extract_existing_pin_options(packages)
72
+ return {} unless @importmap_path.exist?
73
+
74
+ packages = Array(packages)
75
+
76
+ all_package_options = build_package_options_lookup(importmap.lines)
77
+
78
+ packages.to_h do |package|
79
+ [package, all_package_options[package] || {}]
80
+ end
81
+ end
82
+
65
83
  private
84
+ def build_package_options_lookup(lines)
85
+ lines.each_with_object({}) do |line, package_options|
86
+ match = line.strip.match(PIN_REGEX)
87
+
88
+ if match
89
+ package_name = match[1]
90
+ options_part = match[2]
91
+
92
+ preload_match = options_part.match(PRELOAD_OPTION_REGEXP)
93
+
94
+ if preload_match
95
+ preload = preload_from_string(preload_match[1])
96
+ package_options[package_name] = { preload: preload }
97
+ end
98
+ end
99
+ end
100
+ end
101
+
102
+ def preload_from_string(value)
103
+ case value
104
+ when "true"
105
+ true
106
+ when "false"
107
+ false
108
+ when /^\[.*\]$/
109
+ JSON.parse(value)
110
+ else
111
+ value.gsub(/["']/, "")
112
+ end
113
+ end
114
+
115
+ def preload(preloads)
116
+ case Array(preloads)
117
+ in []
118
+ ""
119
+ in ["true"] | [true]
120
+ %(, preload: true)
121
+ in ["false"] | [false]
122
+ %(, preload: false)
123
+ in [string]
124
+ %(, preload: "#{string}")
125
+ else
126
+ %(, preload: #{preloads})
127
+ end
128
+ end
129
+
66
130
  def post_json(body)
67
131
  Net::HTTP.post(self.class.endpoint, body.to_json, "Content-Type" => "application/json")
68
132
  rescue => error
@@ -73,8 +137,13 @@ class Importmap::Packager
73
137
  name.to_s == "jspm" ? "jspm.io" : name.to_s
74
138
  end
75
139
 
76
- def extract_parsed_imports(response)
77
- JSON.parse(response.body).dig("map", "imports")
140
+ def extract_parsed_response(response)
141
+ parsed = JSON.parse(response.body)
142
+ imports = parsed.dig("map", "imports")
143
+
144
+ {
145
+ imports: imports,
146
+ }
78
147
  end
79
148
 
80
149
  def handle_failure_response(response)
@@ -106,7 +175,7 @@ class Importmap::Packager
106
175
 
107
176
  def remove_package_from_importmap(package)
108
177
  all_lines = File.readlines(@importmap_path)
109
- with_lines_removed = all_lines.grep_v(/pin ["']#{package}["']/)
178
+ with_lines_removed = all_lines.grep_v(Importmap::Map.pin_line_regexp_for(package))
110
179
 
111
180
  File.open(@importmap_path, "w") do |file|
112
181
  with_lines_removed.each { |line| file.write(line) }
@@ -1,3 +1,3 @@
1
1
  module Importmap
2
- VERSION = "2.1.0"
2
+ VERSION = "2.2.2"
3
3
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: importmap-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.0
4
+ version: 2.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Heinemeier Hansson
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-12-21 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: railties
@@ -52,7 +51,6 @@ dependencies:
52
51
  - - ">="
53
52
  - !ruby/object:Gem::Version
54
53
  version: 6.0.0
55
- description:
56
54
  email: david@loudthinking.com
57
55
  executables: []
58
56
  extensions: []
@@ -81,7 +79,6 @@ licenses:
81
79
  metadata:
82
80
  homepage_uri: https://github.com/rails/importmap-rails
83
81
  source_code_uri: https://github.com/rails/importmap-rails
84
- post_install_message:
85
82
  rdoc_options: []
86
83
  require_paths:
87
84
  - lib
@@ -96,8 +93,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
96
93
  - !ruby/object:Gem::Version
97
94
  version: '0'
98
95
  requirements: []
99
- rubygems_version: 3.5.22
100
- signing_key:
96
+ rubygems_version: 3.6.7
101
97
  specification_version: 4
102
98
  summary: Use ESM with importmap to manage modern JavaScript in Rails without transpiling
103
99
  or bundling.