importmap-rails 2.1.0 → 2.2.0

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: '079361e5c1e2b84206422fb3f2a5de6f6969c07a29e73d40843986d1549410ce'
4
+ data.tar.gz: 61e335f52eacd06ffb635e4400ac68701e58cb57a9edfd7d38cc53a1a173c7a9
5
5
  SHA512:
6
- metadata.gz: 741aab3cb6747eba7daf39dd5d311c126dfccfb55132c68d1e1cdaafe2b84a9e2596ea4746446cf6f1702d851a109cae3413b97a233889cd8855e347098ee592
7
- data.tar.gz: 3b9ed94a737134bbfcd8be54f95fa5094539b2b6d56b7365362bdf7ac961bb1e727cef396eca0001629d45e2c50578c3af819559a521829f41b4e875733d3e80
6
+ metadata.gz: 1c92850ba94cc450f18b1554675737f94eff235e5b51d2922cbb1433a9cd6f3be065a839a72b93661feb7b1f346177b43fbf558a6b9f30445e232f7446e2071a
7
+ data.tar.gz: 0be5aa11b95db77526ef6d933eeca0197346ba69ce5ef6a5148fb871eb7f986c050399c177cce0ccd8178dcb05ccd69a6d673b8efabadc382fea79284df7105c
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,15 @@ 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
+ pin_all_from 'app/javascript/controllers', under: 'controllers', integrity: true
82
85
  ```
83
86
 
84
87
  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
88
 
89
+ The `integrity: true` option automatically calculates integrity hashes for all files in the directory, providing security benefits without manual hash management.
90
+
86
91
  Allows you to:
87
92
 
88
93
  ```js
@@ -97,19 +102,29 @@ Note: Sprockets used to serve assets (albeit without filename digests) it couldn
97
102
 
98
103
  Importmap for Rails downloads and vendors your npm package dependencies via JavaScript CDNs that provide pre-compiled distribution versions.
99
104
 
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).
105
+ 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
106
 
102
107
  ```bash
103
108
  ./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
109
+ Pinning "react" to vendor/javascript/react.js via download from https://ga.jspm.io/npm:react@19.1.0/index.js
106
110
  ```
107
111
 
108
- This will produce pins in your `config/importmap.rb` like so:
112
+ This will produce a pin in your `config/importmap.rb` like so:
109
113
 
110
114
  ```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
115
+ pin "react" # @19.1.0
116
+ ```
117
+
118
+ Other CDNs like [unpkg.com](https://unpkg.com) and [jsdelivr.com](https://www.jsdelivr.com) can be specified with `--from`:
119
+
120
+ ```bash
121
+ ./bin/importmap pin react --from unpkg
122
+ Pinning "react" to vendor/javascript/react.js via download from https://unpkg.com/react@19.1.0/index.js
123
+ ```
124
+
125
+ ```bash
126
+ ./bin/importmap pin react --from jsdelivr
127
+ Pinning "react" to vendor/javascript/react.js via download from https://cdn.jsdelivr.net/npm/react@19.1.0/index.js
113
128
  ```
114
129
 
115
130
  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,7 +134,137 @@ If you later wish to remove a downloaded pin:
119
134
  ```bash
120
135
  ./bin/importmap unpin react
121
136
  Unpinning and removing "react"
122
- Unpinning and removing "object-assign"
137
+ ```
138
+
139
+ ## Subresource Integrity (SRI)
140
+
141
+ 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.
142
+
143
+ ### Default behavior with integrity
144
+
145
+ When you pin a package, integrity hashes are automatically included:
146
+
147
+ ```bash
148
+ ./bin/importmap pin lodash
149
+ Pinning "lodash" to vendor/javascript/lodash.js via download from https://ga.jspm.io/npm:lodash@4.17.21/lodash.js
150
+ Using integrity: sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF
151
+ ```
152
+
153
+ This generates a pin in your `config/importmap.rb` with the integrity hash:
154
+
155
+ ```ruby
156
+ pin "lodash", integrity: "sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF" # @4.17.21
157
+ ```
158
+
159
+ ### Opting out of integrity
160
+
161
+ If you need to disable integrity checking (not recommended for security reasons), you can use the `--no-integrity` flag:
162
+
163
+ ```bash
164
+ ./bin/importmap pin lodash --no-integrity
165
+ Pinning "lodash" to vendor/javascript/lodash.js via download from https://ga.jspm.io/npm:lodash@4.17.21/lodash.js
166
+ ```
167
+
168
+ This generates a pin without integrity:
169
+
170
+ ```ruby
171
+ pin "lodash" # @4.17.21
172
+ ```
173
+
174
+ ### Adding integrity to existing pins
175
+
176
+ If you have existing pins without integrity hashes, you can add them using the `integrity` command:
177
+
178
+ ```bash
179
+ # Add integrity to specific packages
180
+ ./bin/importmap integrity lodash react
181
+
182
+ # Add integrity to all pinned packages
183
+ ./bin/importmap integrity
184
+
185
+ # Update your importmap.rb file with integrity hashes
186
+ ./bin/importmap integrity --update
187
+ ```
188
+
189
+ ### Automatic integrity for local assets
190
+
191
+ 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:
192
+
193
+ ```ruby
194
+ # config/importmap.rb
195
+
196
+ # Automatically calculate integrity from asset pipeline
197
+ pin "application", integrity: true
198
+ pin "admin", to: "admin.js", integrity: true
199
+
200
+ # Works with pin_all_from too
201
+ pin_all_from "app/javascript/controllers", under: "controllers", integrity: true
202
+ pin_all_from "app/javascript/lib", under: "lib", integrity: true
203
+
204
+ # Mixed usage
205
+ pin "local_module", integrity: true # Auto-calculated
206
+ pin "cdn_package", integrity: "sha384-abc123..." # Pre-calculated
207
+ pin "no_integrity_package" # No integrity (default)
208
+ ```
209
+
210
+ This is particularly useful for:
211
+ * **Local JavaScript files** managed by your Rails asset pipeline
212
+ * **Bulk operations** with `pin_all_from` where calculating hashes manually would be tedious
213
+ * **Development workflow** where asset contents change frequently
214
+
215
+ The `integrity: true` option:
216
+ * Uses the Rails asset pipeline's built-in integrity calculation
217
+ * Works with both Sprockets and Propshaft
218
+ * Automatically updates when assets are recompiled
219
+ * Gracefully handles missing assets (returns `nil` for non-existent files)
220
+
221
+ **Example output with `integrity: true`:**
222
+ ```json
223
+ {
224
+ "imports": {
225
+ "application": "/assets/application-abc123.js",
226
+ "controllers/hello_controller": "/assets/controllers/hello_controller-def456.js"
227
+ },
228
+ "integrity": {
229
+ "/assets/application-abc123.js": "sha256-xyz789...",
230
+ "/assets/controllers/hello_controller-def456.js": "sha256-uvw012..."
231
+ }
232
+ }
233
+ ```
234
+
235
+ ### How integrity works
236
+
237
+ The integrity hashes are automatically included in your import map and module preload tags:
238
+
239
+ **Import map JSON:**
240
+ ```json
241
+ {
242
+ "imports": {
243
+ "lodash": "https://ga.jspm.io/npm:lodash@4.17.21/lodash.js"
244
+ },
245
+ "integrity": {
246
+ "https://ga.jspm.io/npm:lodash@4.17.21/lodash.js": "sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF"
247
+ }
248
+ }
249
+ ```
250
+
251
+ **Module preload tags:**
252
+ ```html
253
+ <link rel="modulepreload" href="https://ga.jspm.io/npm:lodash@4.17.21/lodash.js" integrity="sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF">
254
+ ```
255
+
256
+ Modern browsers will automatically validate these integrity hashes when loading the JavaScript modules, ensuring the files haven't been modified.
257
+
258
+ ### Redownloading packages with integrity
259
+
260
+ The `pristine` command also includes integrity by default:
261
+
262
+ ```bash
263
+ # Redownload all packages with integrity (default)
264
+ ./bin/importmap pristine
265
+
266
+ # Redownload packages without integrity
267
+ ./bin/importmap pristine --no-integrity
123
268
  ```
124
269
 
125
270
  ## Preloading pinned modules
@@ -208,7 +353,7 @@ Pin your js file:
208
353
  pin "checkout", preload: false
209
354
  ```
210
355
 
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.
356
+ 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
357
 
213
358
  ```erb
214
359
  <% 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,20 @@ 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"
16
+ option :integrity, type: :boolean, aliases: :i, default: true, desc: "Include integrity hash from JSPM"
15
17
  def pin(*packages)
16
- if imports = packager.import(*packages, env: options[:env], from: options[:from])
17
- imports.each do |package, url|
18
+ with_import_response(packages, env: options[:env], from: options[:from], integrity: options[:integrity]) do |imports, integrity_hashes|
19
+ process_imports(imports, integrity_hashes) do |package, url, integrity_hash|
18
20
  puts %(Pinning "#{package}" to #{packager.vendor_path}/#{package}.js via download from #{url})
21
+
19
22
  packager.download(package, url)
20
- pin = packager.vendored_pin_for(package, url)
21
23
 
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
24
+ pin = packager.vendored_pin_for(package, url, options[:preload], integrity: integrity_hash)
25
+
26
+ log_integrity_usage(integrity_hash)
27
+ update_importmap_with_pin(package, pin)
27
28
  end
28
- else
29
- puts "Couldn't find any packages in #{packages.inspect} on #{options[:from]}"
30
29
  end
31
30
  end
32
31
 
@@ -34,33 +33,31 @@ class Importmap::Commands < Thor
34
33
  option :env, type: :string, aliases: :e, default: "production"
35
34
  option :from, type: :string, aliases: :f, default: "jspm"
36
35
  def unpin(*packages)
37
- if imports = packager.import(*packages, env: options[:env], from: options[:from])
36
+ with_import_response(packages, env: options[:env], from: options[:from]) do |imports, _integrity_hashes|
38
37
  imports.each do |package, url|
39
38
  if packager.packaged?(package)
40
39
  puts %(Unpinning and removing "#{package}")
41
40
  packager.remove(package)
42
41
  end
43
42
  end
44
- else
45
- puts "Couldn't find any packages in #{packages.inspect} on #{options[:from]}"
46
43
  end
47
44
  end
48
45
 
49
46
  desc "pristine", "Redownload all pinned packages"
50
47
  option :env, type: :string, aliases: :e, default: "production"
51
48
  option :from, type: :string, aliases: :f, default: "jspm"
49
+ option :integrity, type: :boolean, aliases: :i, default: true, desc: "Include integrity hash from JSPM"
52
50
  def pristine
53
- packages = npm.packages_with_versions.map do |p, v|
54
- v.blank? ? p : [p, v].join("@")
55
- end
51
+ packages = prepare_packages_with_versions
56
52
 
57
- if imports = packager.import(*packages, env: options[:env], from: options[:from])
58
- imports.each do |package, url|
53
+ with_import_response(packages, env: options[:env], from: options[:from], integrity: options[:integrity]) do |imports, integrity_hashes|
54
+ process_imports(imports, integrity_hashes) do |package, url, integrity_hash|
59
55
  puts %(Downloading "#{package}" to #{packager.vendor_path}/#{package}.js from #{url})
56
+
60
57
  packager.download(package, url)
58
+
59
+ log_integrity_usage(integrity_hash)
61
60
  end
62
- else
63
- puts "Couldn't find any packages in #{packages.inspect} on #{options[:from]}"
64
61
  end
65
62
  end
66
63
 
@@ -121,6 +118,33 @@ class Importmap::Commands < Thor
121
118
  puts npm.packages_with_versions.map { |x| x.join(' ') }
122
119
  end
123
120
 
121
+ desc "integrity [*PACKAGES]", "Download and add integrity hashes for packages"
122
+ option :env, type: :string, aliases: :e, default: "production"
123
+ option :from, type: :string, aliases: :f, default: "jspm"
124
+ option :update, type: :boolean, aliases: :u, default: false, desc: "Update importmap.rb with integrity hashes"
125
+ def integrity(*packages)
126
+ packages = prepare_packages_with_versions(packages)
127
+
128
+ with_import_response(packages, env: options[:env], from: options[:from], integrity: true) do |imports, integrity_hashes|
129
+ process_imports(imports, integrity_hashes) do |package, url, integrity_hash|
130
+ puts %(Getting integrity for "#{package}" from #{url})
131
+
132
+ if integrity_hash
133
+ puts %( #{package}: #{integrity_hash})
134
+
135
+ if options[:update]
136
+ pin_with_integrity = packager.pin_for(package, url, integrity: integrity_hash)
137
+
138
+ update_importmap_with_pin(package, pin_with_integrity)
139
+ puts %( Updated importmap.rb with integrity for "#{package}")
140
+ end
141
+ else
142
+ puts %( No integrity hash available for "#{package}")
143
+ end
144
+ end
145
+ end
146
+ end
147
+
124
148
  private
125
149
  def packager
126
150
  @packager ||= Importmap::Packager.new
@@ -130,6 +154,22 @@ class Importmap::Commands < Thor
130
154
  @npm ||= Importmap::Npm.new
131
155
  end
132
156
 
157
+ def update_importmap_with_pin(package, pin)
158
+ if packager.packaged?(package)
159
+ gsub_file("config/importmap.rb", /^pin "#{package}".*$/, pin, verbose: false)
160
+ else
161
+ append_to_file("config/importmap.rb", "#{pin}\n", verbose: false)
162
+ end
163
+ end
164
+
165
+ def log_integrity_usage(integrity_hash)
166
+ puts %( Using integrity: #{integrity_hash}) if integrity_hash
167
+ end
168
+
169
+ def handle_package_not_found(packages, from)
170
+ puts "Couldn't find any packages in #{packages.inspect} on #{from}"
171
+ end
172
+
133
173
  def remove_line_from_file(path, pattern)
134
174
  path = File.expand_path(path, destination_root)
135
175
 
@@ -154,6 +194,33 @@ class Importmap::Commands < Thor
154
194
  puts divider if row_number == 0
155
195
  end
156
196
  end
197
+
198
+ def prepare_packages_with_versions(packages = [])
199
+ if packages.empty?
200
+ npm.packages_with_versions.map do |p, v|
201
+ v.blank? ? p : [p, v].join("@")
202
+ end
203
+ else
204
+ packages
205
+ end
206
+ end
207
+
208
+ def process_imports(imports, integrity_hashes, &block)
209
+ imports.each do |package, url|
210
+ integrity_hash = integrity_hashes[url]
211
+ block.call(package, url, integrity_hash)
212
+ end
213
+ end
214
+
215
+ def with_import_response(packages, **options)
216
+ response = packager.import(*packages, **options)
217
+
218
+ if response
219
+ yield response[:imports], response[:integrity]
220
+ else
221
+ handle_package_not_found(packages, options[:from])
222
+ end
223
+ end
157
224
  end
158
225
 
159
226
  Importmap::Commands.start(ARGV)
data/lib/importmap/map.rb CHANGED
@@ -25,14 +25,14 @@ class Importmap::Map
25
25
  self
26
26
  end
27
27
 
28
- def pin(name, to: nil, preload: true)
28
+ def pin(name, to: nil, preload: true, integrity: nil)
29
29
  clear_cache
30
- @packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload)
30
+ @packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload, integrity: integrity)
31
31
  end
32
32
 
33
- def pin_all_from(dir, under: nil, to: nil, preload: true)
33
+ def pin_all_from(dir, under: nil, to: nil, preload: true, integrity: nil)
34
34
  clear_cache
35
- @directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload)
35
+ @directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload, integrity: integrity)
36
36
  end
37
37
 
38
38
  # Returns an array of all the resolved module paths of the pinned packages. The `resolver` must respond to
@@ -41,8 +41,72 @@ class Importmap::Map
41
41
  # resolve for different asset hosts, you can pass in a custom `cache_key` to vary the cache used by this method for
42
42
  # the different cases.
43
43
  def preloaded_module_paths(resolver:, entry_point: "application", cache_key: :preloaded_module_paths)
44
+ preloaded_module_packages(resolver: resolver, entry_point: entry_point, cache_key: cache_key).keys
45
+ end
46
+
47
+ # Returns a hash of resolved module paths to their corresponding package objects for all pinned packages
48
+ # that are marked for preloading. The hash keys are the resolved asset paths, and the values are the
49
+ # +MappedFile+ objects containing package metadata including name, path, preload setting, and integrity.
50
+ #
51
+ # The +resolver+ must respond to +path_to_asset+, such as +ActionController::Base.helpers+ or
52
+ # +ApplicationController.helpers+. You'll want to use the resolver that has been configured for the
53
+ # +asset_host+ you want these resolved paths to use.
54
+ #
55
+ # ==== Parameters
56
+ #
57
+ # [+resolver+]
58
+ # An object that responds to +path_to_asset+ for resolving asset paths.
59
+ #
60
+ # [+entry_point+]
61
+ # The entry point name or array of entry point names to determine which packages should be preloaded.
62
+ # Defaults to +"application"+. Packages with +preload: true+ are always included regardless of entry point.
63
+ # Packages with specific entry point names (e.g., +preload: "admin"+) are only included when that entry
64
+ # point is specified.
65
+ #
66
+ # [+cache_key+]
67
+ # A custom cache key to vary the cache used by this method for different cases, such as resolving
68
+ # for different asset hosts. Defaults to +:preloaded_module_packages+.
69
+ #
70
+ # ==== Returns
71
+ #
72
+ # A hash where:
73
+ # * Keys are resolved asset paths (strings)
74
+ # * Values are +MappedFile+ objects with +name+, +path+, +preload+, and +integrity+ attributes
75
+ #
76
+ # Missing assets are gracefully handled and excluded from the returned hash.
77
+ #
78
+ # ==== Examples
79
+ #
80
+ # # Get all preloaded packages for the default "application" entry point
81
+ # packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers)
82
+ # # => { "/assets/application-abc123.js" => #<struct name="application", path="application.js", preload=true, integrity=nil>,
83
+ # # "https://cdn.skypack.dev/react" => #<struct name="react", path="https://cdn.skypack.dev/react", preload=true, integrity="sha384-..."> }
84
+ #
85
+ # # Get preloaded packages for a specific entry point
86
+ # packages = importmap.preloaded_module_packages(resolver: helpers, entry_point: "admin")
87
+ #
88
+ # # Get preloaded packages for multiple entry points
89
+ # packages = importmap.preloaded_module_packages(resolver: helpers, entry_point: ["application", "admin"])
90
+ #
91
+ # # Use a custom cache key for different asset hosts
92
+ # packages = importmap.preloaded_module_packages(resolver: helpers, cache_key: "cdn_host")
93
+ def preloaded_module_packages(resolver:, entry_point: "application", cache_key: :preloaded_module_packages)
44
94
  cache_as(cache_key) do
45
- resolve_asset_paths(expanded_preloading_packages_and_directories(entry_point:), resolver:).values
95
+ expanded_preloading_packages_and_directories(entry_point:).filter_map do |_, package|
96
+ resolved_path = resolve_asset_path(package.path, resolver: resolver)
97
+ next unless resolved_path
98
+
99
+ resolved_integrity = resolve_integrity_value(package.integrity, package.path, resolver: resolver)
100
+
101
+ package = MappedFile.new(
102
+ name: package.name,
103
+ path: package.path,
104
+ preload: package.preload,
105
+ integrity: resolved_integrity
106
+ )
107
+
108
+ [resolved_path, package]
109
+ end.to_h
46
110
  end
47
111
  end
48
112
 
@@ -53,7 +117,9 @@ class Importmap::Map
53
117
  # `cache_key` to vary the cache used by this method for the different cases.
54
118
  def to_json(resolver:, cache_key: :json)
55
119
  cache_as(cache_key) do
56
- JSON.pretty_generate({ "imports" => resolve_asset_paths(expanded_packages_and_directories, resolver: resolver) })
120
+ packages = expanded_packages_and_directories
121
+ map = build_import_map(packages, resolver: resolver)
122
+ JSON.pretty_generate(map)
57
123
  end
58
124
  end
59
125
 
@@ -84,8 +150,8 @@ class Importmap::Map
84
150
  end
85
151
 
86
152
  private
87
- MappedDir = Struct.new(:dir, :path, :under, :preload, keyword_init: true)
88
- MappedFile = Struct.new(:name, :path, :preload, keyword_init: true)
153
+ MappedDir = Struct.new(:dir, :path, :under, :preload, :integrity, keyword_init: true)
154
+ MappedFile = Struct.new(:name, :path, :preload, :integrity, keyword_init: true)
89
155
 
90
156
  def cache_as(name)
91
157
  if result = @cache[name.to_s]
@@ -105,19 +171,53 @@ class Importmap::Map
105
171
 
106
172
  def resolve_asset_paths(paths, resolver:)
107
173
  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
174
+ resolve_asset_path(mapping.path, resolver:)
118
175
  end.compact
119
176
  end
120
177
 
178
+ def resolve_asset_path(path, resolver:)
179
+ begin
180
+ resolver.path_to_asset(path)
181
+ rescue => e
182
+ if rescuable_asset_error?(e)
183
+ Rails.logger.warn "Importmap skipped missing path: #{path}"
184
+ nil
185
+ else
186
+ raise e
187
+ end
188
+ end
189
+ end
190
+
191
+ def build_import_map(packages, resolver:)
192
+ map = { "imports" => resolve_asset_paths(packages, resolver: resolver) }
193
+ integrity = build_integrity_hash(packages, resolver: resolver)
194
+ map["integrity"] = integrity unless integrity.empty?
195
+ map
196
+ end
197
+
198
+ def build_integrity_hash(packages, resolver:)
199
+ packages.filter_map do |name, mapping|
200
+ next unless mapping.integrity
201
+
202
+ resolved_path = resolve_asset_path(mapping.path, resolver: resolver)
203
+ next unless resolved_path
204
+
205
+ integrity_value = resolve_integrity_value(mapping.integrity, mapping.path, resolver: resolver)
206
+ next unless integrity_value
207
+
208
+ [resolved_path, integrity_value]
209
+ end.to_h
210
+ end
211
+
212
+ def resolve_integrity_value(integrity, path, resolver:)
213
+ case integrity
214
+ when true
215
+ resolver.asset_integrity(path) if resolver.respond_to?(:asset_integrity)
216
+ when String
217
+ integrity
218
+ end
219
+ end
220
+
121
221
  def expanded_preloading_packages_and_directories(entry_point:)
122
222
  expanded_packages_and_directories.select { |name, mapping| mapping.preload.in?([true, false]) ? mapping.preload : (Array(mapping.preload) & Array(entry_point)).any? }
123
223
  end
@@ -134,7 +234,12 @@ class Importmap::Map
134
234
  module_name = module_name_from(module_filename, mapping)
135
235
  module_path = module_path_from(module_filename, mapping)
136
236
 
137
- paths[module_name] = MappedFile.new(name: module_name, path: module_path, preload: mapping.preload)
237
+ paths[module_name] = MappedFile.new(
238
+ name: module_name,
239
+ path: module_path,
240
+ preload: mapping.preload,
241
+ integrity: mapping.integrity
242
+ )
138
243
  end
139
244
  end
140
245
  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 = /^pin ["']([^["']]*)["'].*/
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
20
  packages_with_versions.each.with_object([]) do |(package, current_version), outdated_packages|
18
- outdated_package = OutdatedPackage.new(name: package,
19
- current_version: current_version)
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+)@(\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
@@ -17,34 +17,39 @@ class Importmap::Packager
17
17
  @vendor_path = Pathname.new(vendor_path)
18
18
  end
19
19
 
20
- def import(*packages, env: "production", from: "jspm")
20
+ def import(*packages, env: "production", from: "jspm", integrity: false)
21
21
  response = post_json({
22
22
  "install" => Array(packages),
23
23
  "flattenScope" => true,
24
24
  "env" => [ "browser", "module", env ],
25
- "provider" => normalize_provider(from)
25
+ "provider" => normalize_provider(from),
26
+ "integrity" => integrity
26
27
  })
27
28
 
28
29
  case response.code
29
- when "200" then extract_parsed_imports(response)
30
- when "404", "401" then nil
31
- else handle_failure_response(response)
30
+ when "200"
31
+ extract_parsed_response(response)
32
+ when "404", "401"
33
+ nil
34
+ else
35
+ handle_failure_response(response)
32
36
  end
33
37
  end
34
38
 
35
- def pin_for(package, url)
36
- %(pin "#{package}", to: "#{url}")
39
+ def pin_for(package, url = nil, preloads: nil, integrity: nil)
40
+ to = url ? %(, to: "#{url}") : ""
41
+ preload_param = preload(preloads)
42
+ integrity_param = integrity ? %(, integrity: "#{integrity}") : ""
43
+
44
+ %(pin "#{package}") + to + preload_param + integrity_param
37
45
  end
38
46
 
39
- def vendored_pin_for(package, url)
47
+ def vendored_pin_for(package, url, preloads = nil, integrity: nil)
40
48
  filename = package_filename(package)
41
49
  version = extract_package_version_from(url)
50
+ to = "#{package}.js" != filename ? filename : nil
42
51
 
43
- if "#{package}.js" == filename
44
- %(pin "#{package}" # #{version})
45
- else
46
- %(pin "#{package}", to: "#{filename}" # #{version})
47
- end
52
+ pin_for(package, to, preloads: preloads, integrity: integrity) + %( # #{version})
48
53
  end
49
54
 
50
55
  def packaged?(package)
@@ -63,6 +68,21 @@ class Importmap::Packager
63
68
  end
64
69
 
65
70
  private
71
+ def preload(preloads)
72
+ case Array(preloads)
73
+ in []
74
+ ""
75
+ in ["true"]
76
+ %(, preload: true)
77
+ in ["false"]
78
+ %(, preload: false)
79
+ in [string]
80
+ %(, preload: "#{string}")
81
+ else
82
+ %(, preload: #{preloads})
83
+ end
84
+ end
85
+
66
86
  def post_json(body)
67
87
  Net::HTTP.post(self.class.endpoint, body.to_json, "Content-Type" => "application/json")
68
88
  rescue => error
@@ -73,8 +93,15 @@ class Importmap::Packager
73
93
  name.to_s == "jspm" ? "jspm.io" : name.to_s
74
94
  end
75
95
 
76
- def extract_parsed_imports(response)
77
- JSON.parse(response.body).dig("map", "imports")
96
+ def extract_parsed_response(response)
97
+ parsed = JSON.parse(response.body)
98
+ imports = parsed.dig("map", "imports")
99
+ integrity = parsed.dig("map", "integrity") || {}
100
+
101
+ {
102
+ imports: imports,
103
+ integrity: integrity
104
+ }
78
105
  end
79
106
 
80
107
  def handle_failure_response(response)
@@ -1,3 +1,3 @@
1
1
  module Importmap
2
- VERSION = "2.1.0"
2
+ VERSION = "2.2.0"
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.0
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.