importmap-rails 2.1.0 → 2.2.1

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: c286a2385b19e8a170d991478ce228cfb1c0e27eb02fbda6349a889798caa85f
4
+ data.tar.gz: a40cea607756433bf36ad49e291e342f324a303c80e0286e980ee27c6c9001f9
5
5
  SHA512:
6
- metadata.gz: 741aab3cb6747eba7daf39dd5d311c126dfccfb55132c68d1e1cdaafe2b84a9e2596ea4746446cf6f1702d851a109cae3413b97a233889cd8855e347098ee592
7
- data.tar.gz: 3b9ed94a737134bbfcd8be54f95fa5094539b2b6d56b7365362bdf7ac961bb1e727cef396eca0001629d45e2c50578c3af819559a521829f41b4e875733d3e80
6
+ metadata.gz: 71fc6847c3860c86d611dad36554feab750059128eee5d72961da0cd6bc2ba6ec08dc96358bb7919f487243d7463ca304efbbbf68874d7b58c9b85580d652f75
7
+ data.tar.gz: 59b0d5ce601ef182abd5f1017f414eec8fea35d1e48e270b0edd10bcf0c59cd7c5e52c1af0dab4f98f0ea81972ce82004af3011d1f3399d152ce077fbbd7a7e1
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,9 +134,89 @@ 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"
123
137
  ```
124
138
 
139
+ ## Subresource Integrity (SRI)
140
+
141
+ 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.
142
+
143
+ ### Automatic integrity for local assets
144
+
145
+ Starting with importmap-rails, **`integrity: true` is the default** for all pins. This automatically calculates integrity hashes for local assets served by the Rails asset pipeline:
146
+
147
+ ```ruby
148
+ # config/importmap.rb
149
+
150
+ # These all use integrity: true by default
151
+ pin "application" # Auto-calculated integrity
152
+ pin "admin", to: "admin.js" # Auto-calculated integrity
153
+ pin_all_from "app/javascript/controllers", under: "controllers" # Auto-calculated integrity
154
+
155
+ # Mixed usage - explicitly controlling integrity
156
+ pin "cdn_package", integrity: "sha384-abc123..." # Pre-calculated hash
157
+ pin "no_integrity_package", integrity: false # Explicitly disable integrity
158
+ pin "nil_integrity_package", integrity: nil # Explicitly disable integrity
159
+ ```
160
+
161
+ This is particularly useful for:
162
+ * **Local JavaScript files** managed by your Rails asset pipeline
163
+ * **Bulk operations** with `pin_all_from` where calculating hashes manually would be tedious
164
+ * **Development workflow** where asset contents change frequently
165
+
166
+ This behavior can be disabled by setting `integrity: false` or `integrity: nil`
167
+
168
+ **Important for Propshaft users:** SRI support requires Propshaft 1.2+ and you must configure the integrity hash algorithm in your application:
169
+
170
+ ```ruby
171
+ # config/application.rb or config/environments/*.rb
172
+ config.assets.integrity_hash_algorithm = 'sha256' # or 'sha384', 'sha512'
173
+ ```
174
+
175
+ Without this configuration, integrity will be disabled by default when using Propshaft. Sprockets includes integrity support out of the box.
176
+
177
+ **Example output with `integrity: true`:**
178
+ ```json
179
+ {
180
+ "imports": {
181
+ "application": "/assets/application-abc123.js",
182
+ "controllers/hello_controller": "/assets/controllers/hello_controller-def456.js"
183
+ },
184
+ "integrity": {
185
+ "/assets/application-abc123.js": "sha256-xyz789...",
186
+ "/assets/controllers/hello_controller-def456.js": "sha256-uvw012..."
187
+ }
188
+ }
189
+ ```
190
+
191
+ ### How integrity works
192
+
193
+ The integrity hashes are automatically included in your import map and module preload tags:
194
+
195
+ **Import map JSON:**
196
+ ```json
197
+ {
198
+ "imports": {
199
+ "lodash": "https://ga.jspm.io/npm:lodash@4.17.21/lodash.js",
200
+ "application": "/assets/application-abc123.js",
201
+ "controllers/hello_controller": "/assets/controllers/hello_controller-def456.js"
202
+ },
203
+ "integrity": {
204
+ "https://ga.jspm.io/npm:lodash@4.17.21/lodash.js": "sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF"
205
+ "/assets/application-abc123.js": "sha256-xyz789...",
206
+ "/assets/controllers/hello_controller-def456.js": "sha256-uvw012..."
207
+ }
208
+ }
209
+ ```
210
+
211
+ **Module preload tags:**
212
+ ```html
213
+ <link rel="modulepreload" href="https://ga.jspm.io/npm:lodash@4.17.21/lodash.js" integrity="sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF">
214
+ <link rel="modulepreload" href="/assets/application-abc123.js" integrity="sha256-xyz789...">
215
+ <link rel="modulepreload" href="/assets/controllers/hello_controller-def456.js" integrity="sha256-uvw012...">
216
+ ```
217
+
218
+ Modern browsers will automatically validate these integrity hashes when loading the JavaScript modules, ensuring the files haven't been modified.
219
+
125
220
  ## Preloading pinned modules
126
221
 
127
222
  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 +303,7 @@ Pin your js file:
208
303
  pin "checkout", preload: false
209
304
  ```
210
305
 
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.
306
+ 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
307
 
213
308
  ```erb
214
309
  <% 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,16 @@ 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
+ puts %(Pinning "#{package}" to #{packager.vendor_path}/#{package}.js via download from #{url})
19
+
20
+ packager.download(package, url)
21
+
22
+ pin = packager.vendored_pin_for(package, url, options[:preload])
23
+
24
+ update_importmap_with_pin(package, pin)
30
25
  end
31
26
  end
32
27
 
@@ -34,15 +29,11 @@ class Importmap::Commands < Thor
34
29
  option :env, type: :string, aliases: :e, default: "production"
35
30
  option :from, type: :string, aliases: :f, default: "jspm"
36
31
  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
32
+ for_each_import(packages, env: options[:env], from: options[:from]) do |package, url|
33
+ if packager.packaged?(package)
34
+ puts %(Unpinning and removing "#{package}")
35
+ packager.remove(package)
43
36
  end
44
- else
45
- puts "Couldn't find any packages in #{packages.inspect} on #{options[:from]}"
46
37
  end
47
38
  end
48
39
 
@@ -50,17 +41,12 @@ class Importmap::Commands < Thor
50
41
  option :env, type: :string, aliases: :e, default: "production"
51
42
  option :from, type: :string, aliases: :f, default: "jspm"
52
43
  def pristine
53
- packages = npm.packages_with_versions.map do |p, v|
54
- v.blank? ? p : [p, v].join("@")
55
- end
44
+ packages = prepare_packages_with_versions
56
45
 
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]}"
46
+ for_each_import(packages, env: options[:env], from: options[:from]) do |package, url|
47
+ puts %(Downloading "#{package}" to #{packager.vendor_path}/#{package}.js from #{url})
48
+
49
+ packager.download(package, url)
64
50
  end
65
51
  end
66
52
 
@@ -130,6 +116,18 @@ class Importmap::Commands < Thor
130
116
  @npm ||= Importmap::Npm.new
131
117
  end
132
118
 
119
+ def update_importmap_with_pin(package, pin)
120
+ if packager.packaged?(package)
121
+ gsub_file("config/importmap.rb", /^pin "#{package}".*$/, pin, verbose: false)
122
+ else
123
+ append_to_file("config/importmap.rb", "#{pin}\n", verbose: false)
124
+ end
125
+ end
126
+
127
+ def handle_package_not_found(packages, from)
128
+ puts "Couldn't find any packages in #{packages.inspect} on #{from}"
129
+ end
130
+
133
131
  def remove_line_from_file(path, pattern)
134
132
  path = File.expand_path(path, destination_root)
135
133
 
@@ -154,6 +152,26 @@ class Importmap::Commands < Thor
154
152
  puts divider if row_number == 0
155
153
  end
156
154
  end
155
+
156
+ def prepare_packages_with_versions(packages = [])
157
+ if packages.empty?
158
+ npm.packages_with_versions.map do |p, v|
159
+ v.blank? ? p : [p, v].join("@")
160
+ end
161
+ else
162
+ packages
163
+ end
164
+ end
165
+
166
+ def for_each_import(packages, **options, &block)
167
+ response = packager.import(*packages, **options)
168
+
169
+ if response
170
+ response[:imports].each(&block)
171
+ else
172
+ handle_package_not_found(packages, options[:from])
173
+ end
174
+ end
157
175
  end
158
176
 
159
177
  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: true)
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: true)
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
@@ -22,29 +22,32 @@ class Importmap::Packager
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
26
  })
27
27
 
28
28
  case response.code
29
- when "200" then extract_parsed_imports(response)
30
- when "404", "401" then nil
31
- else handle_failure_response(response)
29
+ when "200"
30
+ extract_parsed_response(response)
31
+ when "404", "401"
32
+ nil
33
+ else
34
+ handle_failure_response(response)
32
35
  end
33
36
  end
34
37
 
35
- def pin_for(package, url)
36
- %(pin "#{package}", to: "#{url}")
38
+ def pin_for(package, url = nil, preloads: nil)
39
+ to = url ? %(, to: "#{url}") : ""
40
+ preload_param = preload(preloads)
41
+
42
+ %(pin "#{package}") + to + preload_param
37
43
  end
38
44
 
39
- def vendored_pin_for(package, url)
45
+ def vendored_pin_for(package, url, preloads = nil)
40
46
  filename = package_filename(package)
41
47
  version = extract_package_version_from(url)
48
+ to = "#{package}.js" != filename ? filename : nil
42
49
 
43
- if "#{package}.js" == filename
44
- %(pin "#{package}" # #{version})
45
- else
46
- %(pin "#{package}", to: "#{filename}" # #{version})
47
- end
50
+ pin_for(package, to, preloads: preloads) + %( # #{version})
48
51
  end
49
52
 
50
53
  def packaged?(package)
@@ -63,6 +66,21 @@ class Importmap::Packager
63
66
  end
64
67
 
65
68
  private
69
+ def preload(preloads)
70
+ case Array(preloads)
71
+ in []
72
+ ""
73
+ in ["true"]
74
+ %(, preload: true)
75
+ in ["false"]
76
+ %(, preload: false)
77
+ in [string]
78
+ %(, preload: "#{string}")
79
+ else
80
+ %(, preload: #{preloads})
81
+ end
82
+ end
83
+
66
84
  def post_json(body)
67
85
  Net::HTTP.post(self.class.endpoint, body.to_json, "Content-Type" => "application/json")
68
86
  rescue => error
@@ -73,8 +91,13 @@ class Importmap::Packager
73
91
  name.to_s == "jspm" ? "jspm.io" : name.to_s
74
92
  end
75
93
 
76
- def extract_parsed_imports(response)
77
- JSON.parse(response.body).dig("map", "imports")
94
+ def extract_parsed_response(response)
95
+ parsed = JSON.parse(response.body)
96
+ imports = parsed.dig("map", "imports")
97
+
98
+ {
99
+ imports: imports,
100
+ }
78
101
  end
79
102
 
80
103
  def handle_failure_response(response)
@@ -1,3 +1,3 @@
1
1
  module Importmap
2
- VERSION = "2.1.0"
2
+ VERSION = "2.2.1"
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.1
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.