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 +4 -4
- data/README.md +110 -11
- data/app/helpers/importmap/importmap_tags_helper.rb +14 -4
- data/lib/importmap/commands.rb +64 -33
- data/lib/importmap/map.rb +170 -19
- data/lib/importmap/npm.rb +58 -17
- data/lib/importmap/packager.rb +85 -16
- data/lib/importmap/version.rb +1 -1
- metadata +3 -7
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 91837e96107f59efa73a314dec3955a488f9f40826c27e116da43c5d9ef0a44a
|
4
|
+
data.tar.gz: ce9d44539cd6bb86b1e027c9c714dc8274b4a7b918ee0a67493b79b9eb58103f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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 "
|
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.
|
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@
|
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
|
113
|
+
This will produce a pin in your `config/importmap.rb` like so:
|
109
114
|
|
110
115
|
```ruby
|
111
|
-
pin "react" #
|
112
|
-
|
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
|
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
|
-
|
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
|
-
|
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
|
data/lib/importmap/commands.rb
CHANGED
@@ -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
|
-
|
17
|
-
|
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
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
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 =
|
54
|
-
v.blank? ? p : [p, v].join("@")
|
55
|
-
end
|
38
|
+
packages = prepare_packages_with_versions
|
56
39
|
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
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
|
-
|
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
|
-
|
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
|
-
@
|
69
|
+
@integrity = true
|
31
70
|
end
|
32
71
|
|
33
|
-
def
|
72
|
+
def pin(name, to: nil, preload: true, integrity: true)
|
34
73
|
clear_cache
|
35
|
-
@
|
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
|
-
|
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
|
-
|
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
|
-
|
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(
|
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.
|
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(
|
40
|
-
|
41
|
-
|
42
|
-
|
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
|
-
|
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 =
|
80
|
-
|
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
|
data/lib/importmap/packager.rb
CHANGED
@@ -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"
|
30
|
-
|
31
|
-
|
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
|
-
%(
|
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
|
-
|
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(
|
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
|
77
|
-
JSON.parse(response.body)
|
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(
|
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) }
|
data/lib/importmap/version.rb
CHANGED
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.
|
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:
|
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.
|
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.
|