importmap-rails 0.8.0 → 2.2.3

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.
@@ -1,5 +1,6 @@
1
1
  require "thor"
2
2
  require "importmap/packager"
3
+ require "importmap/npm"
3
4
 
4
5
  class Importmap::Commands < Thor
5
6
  include Thor::Actions
@@ -7,53 +8,39 @@ class Importmap::Commands < Thor
7
8
  def self.exit_on_failure?
8
9
  false
9
10
  end
10
-
11
+
11
12
  desc "pin [*PACKAGES]", "Pin new packages"
12
13
  option :env, type: :string, aliases: :e, default: "production"
13
14
  option :from, type: :string, aliases: :f, default: "jspm"
14
- option :download, type: :boolean, aliases: :d, default: false
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
- if options[:download]
19
- puts %(Pinning "#{package}" to vendor/#{package}.js via download from #{url})
20
- packager.download(package, url)
21
- pin = packager.vendored_pin_for(package, url)
22
- else
23
- puts %(Pinning "#{package}" to #{url})
24
- pin = packager.pin_for(package, url)
25
- end
26
-
27
- if packager.packaged?(package)
28
- gsub_file("config/importmap.rb", /^pin "#{package}".*$/, pin, verbose: false)
29
- else
30
- append_to_file("config/importmap.rb", "#{pin}\n", verbose: false)
31
- end
32
- end
33
- else
34
- puts "Couldn't find any packages in #{packages.inspect} on #{options[:provider]}"
17
+ for_each_import(packages, env: options[:env], from: options[:from]) do |package, url|
18
+ pin_package(package, url, options[:preload])
35
19
  end
36
20
  end
37
21
 
38
22
  desc "unpin [*PACKAGES]", "Unpin existing packages"
39
23
  option :env, type: :string, aliases: :e, default: "production"
40
24
  option :from, type: :string, aliases: :f, default: "jspm"
41
- option :download, type: :boolean, aliases: :d, default: false
42
25
  def unpin(*packages)
43
- if imports = packager.import(*packages, env: options[:env], from: options[:from])
44
- imports.each do |package, url|
45
- if packager.packaged?(package)
46
- if options[:download]
47
- puts %(Unpinning and removing "#{package}")
48
- else
49
- puts %(Unpinning "#{package}")
50
- end
51
-
52
- packager.remove(package)
53
- 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)
54
30
  end
55
- else
56
- puts "Couldn't find any packages in #{packages.inspect} on #{options[:provider]}"
31
+ end
32
+ end
33
+
34
+ desc "pristine", "Redownload all pinned packages"
35
+ option :env, type: :string, aliases: :e, default: "production"
36
+ option :from, type: :string, aliases: :f, default: "jspm"
37
+ def pristine
38
+ packages = prepare_packages_with_versions
39
+
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)
57
44
  end
58
45
  end
59
46
 
@@ -63,11 +50,97 @@ class Importmap::Commands < Thor
63
50
  puts Rails.application.importmap.to_json(resolver: ActionController::Base.helpers)
64
51
  end
65
52
 
53
+ desc "audit", "Run a security audit"
54
+ def audit
55
+ vulnerable_packages = npm.vulnerable_packages
56
+
57
+ if vulnerable_packages.any?
58
+ table = [["Package", "Severity", "Vulnerable versions", "Vulnerability"]]
59
+ vulnerable_packages.each { |p| table << [p.name, p.severity, p.vulnerable_versions, p.vulnerability] }
60
+
61
+ puts_table(table)
62
+ vulnerabilities = 'vulnerability'.pluralize(vulnerable_packages.size)
63
+ severities = vulnerable_packages.map(&:severity).tally.sort_by(&:last).reverse
64
+ .map { |severity, count| "#{count} #{severity}" }
65
+ .join(", ")
66
+ puts " #{vulnerable_packages.size} #{vulnerabilities} found: #{severities}"
67
+
68
+ exit 1
69
+ else
70
+ puts "No vulnerable packages found"
71
+ end
72
+ end
73
+
74
+ desc "outdated", "Check for outdated packages"
75
+ def outdated
76
+ if (outdated_packages = npm.outdated_packages).any?
77
+ table = [["Package", "Current", "Latest"]]
78
+ outdated_packages.each { |p| table << [p.name, p.current_version, p.latest_version || p.error] }
79
+
80
+ puts_table(table)
81
+ packages = 'package'.pluralize(outdated_packages.size)
82
+ puts " #{outdated_packages.size} outdated #{packages} found"
83
+
84
+ exit 1
85
+ else
86
+ puts "No outdated packages found"
87
+ end
88
+ end
89
+
90
+ desc "update", "Update outdated package pins"
91
+ def update
92
+ if (outdated_packages = npm.outdated_packages).any?
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
101
+ else
102
+ puts "No outdated packages found"
103
+ end
104
+ end
105
+
106
+ desc "packages", "Print out packages with version numbers"
107
+ def packages
108
+ puts npm.packages_with_versions.map { |x| x.join(' ') }
109
+ end
110
+
66
111
  private
67
112
  def packager
68
113
  @packager ||= Importmap::Packager.new
69
114
  end
70
115
 
116
+ def npm
117
+ @npm ||= Importmap::Npm.new
118
+ end
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
+
71
144
  def remove_line_from_file(path, pattern)
72
145
  path = File.expand_path(path, destination_root)
73
146
 
@@ -78,6 +151,40 @@ class Importmap::Commands < Thor
78
151
  with_lines_removed.each { |line| file.write(line) }
79
152
  end
80
153
  end
154
+
155
+ def puts_table(array)
156
+ column_sizes = array.reduce([]) do |lengths, row|
157
+ row.each_with_index.map{ |iterand, index| [lengths[index] || 0, iterand.to_s.length].max }
158
+ end
159
+
160
+ divider = "|" + (column_sizes.map { |s| "-" * (s + 2) }.join('|')) + '|'
161
+ array.each_with_index do |row, row_number|
162
+ row = row.fill(nil, row.size..(column_sizes.size - 1))
163
+ row = row.each_with_index.map { |v, i| v.to_s + " " * (column_sizes[i] - v.to_s.length) }
164
+ puts "| " + row.join(" | ") + " |"
165
+ puts divider if row_number == 0
166
+ end
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
81
188
  end
82
189
 
83
190
  Importmap::Commands.start(ARGV)
@@ -6,29 +6,34 @@ Rails::Application.send(:attr_accessor, :importmap)
6
6
  module Importmap
7
7
  class Engine < ::Rails::Engine
8
8
  config.importmap = ActiveSupport::OrderedOptions.new
9
+ config.importmap.paths = []
9
10
  config.importmap.sweep_cache = Rails.env.development? || Rails.env.test?
11
+ config.importmap.cache_sweepers = []
12
+ config.importmap.rescuable_asset_errors = []
10
13
 
11
- config.autoload_once_paths = %W( #{root}/app/helpers )
14
+ config.autoload_once_paths = %W( #{root}/app/helpers #{root}/app/controllers )
12
15
 
13
16
  initializer "importmap" do |app|
14
- app.importmap = Importmap::Map.new.draw("config/importmap.rb")
17
+ app.importmap = Importmap::Map.new
18
+ app.config.importmap.paths << app.root.join("config/importmap.rb")
19
+ app.config.importmap.paths.each { |path| app.importmap.draw(path) }
15
20
  end
16
21
 
17
22
  initializer "importmap.reloader" do |app|
18
- app.config.paths.add "config/importmap.rb"
19
-
20
- Importmap::Reloader.new.tap do |reloader|
21
- reloader.execute
22
- app.reloaders << reloader
23
- app.reloader.to_run { reloader.execute }
23
+ unless app.config.cache_classes
24
+ Importmap::Reloader.new.tap do |reloader|
25
+ reloader.execute
26
+ app.reloaders << reloader
27
+ app.reloader.to_run { reloader.execute }
28
+ end
24
29
  end
25
30
  end
26
31
 
27
32
  initializer "importmap.cache_sweeper" do |app|
28
- if app.config.importmap.sweep_cache
29
- app.importmap.cache_sweeper watches: [
30
- app.root.join("app/javascript"), app.root.join("vendor/javascript")
31
- ]
33
+ if app.config.importmap.sweep_cache && !app.config.cache_classes
34
+ app.config.importmap.cache_sweepers << app.root.join("app/javascript")
35
+ app.config.importmap.cache_sweepers << app.root.join("vendor/javascript")
36
+ app.importmap.cache_sweeper(watches: app.config.importmap.cache_sweepers)
32
37
 
33
38
  ActiveSupport.on_load(:action_controller_base) do
34
39
  before_action { Rails.application.importmap.cache_sweeper.execute_if_updated }
@@ -36,11 +41,16 @@ module Importmap
36
41
  end
37
42
  end
38
43
 
39
- initializer "importmap.assets" do
40
- if Rails.application.config.respond_to?(:assets)
41
- Rails.application.config.assets.precompile += %w( es-module-shims.js es-module-shims.min.js )
42
- Rails.application.config.assets.paths << Rails.root.join("app/javascript")
43
- Rails.application.config.assets.paths << Rails.root.join("vendor/javascript")
44
+ initializer "importmap.assets" do |app|
45
+ if app.config.respond_to?(:assets)
46
+ app.config.assets.paths << Rails.root.join("app/javascript")
47
+ app.config.assets.paths << Rails.root.join("vendor/javascript")
48
+ end
49
+ end
50
+
51
+ initializer "importmap.concerns" do
52
+ ActiveSupport.on_load(:action_controller_base) do
53
+ extend Importmap::Freshness
44
54
  end
45
55
  end
46
56
 
@@ -49,5 +59,15 @@ module Importmap
49
59
  helper Importmap::ImportmapTagsHelper
50
60
  end
51
61
  end
62
+
63
+ initializer "importmap.rescuable_asset_errors" do |app|
64
+ if defined?(Propshaft)
65
+ app.config.importmap.rescuable_asset_errors << Propshaft::MissingAssetError
66
+ end
67
+
68
+ if defined?(Sprockets::Rails)
69
+ app.config.importmap.rescuable_asset_errors << Sprockets::Rails::Helper::AssetNotFound
70
+ end
71
+ end
52
72
  end
53
73
  end
data/lib/importmap/map.rb CHANGED
@@ -3,17 +3,27 @@ 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
+
12
+ class InvalidFile < StandardError; end
13
+
6
14
  def initialize
15
+ @integrity = false
7
16
  @packages, @directories = {}, {}
17
+ @cache = {}
8
18
  end
9
19
 
10
20
  def draw(path = nil, &block)
11
21
  if path && File.exist?(path)
12
22
  begin
13
23
  instance_eval(File.read(path), path.to_s)
14
- rescue Exception => e
24
+ rescue StandardError => e
15
25
  Rails.logger.error "Unable to parse import map from #{path}: #{e.message}"
16
- raise "Unable to parse import map from #{path}: #{e.message}"
26
+ raise InvalidFile, "Unable to parse import map from #{path}: #{e.message}"
17
27
  end
18
28
  elsif block_given?
19
29
  instance_eval(&block)
@@ -22,25 +32,138 @@ class Importmap::Map
22
32
  self
23
33
  end
24
34
 
25
- def pin(name, to: nil, preload: false)
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!
26
68
  clear_cache
27
- @packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload)
69
+ @integrity = true
28
70
  end
29
71
 
30
- def pin_all_from(dir, under: nil, to: nil, preload: false)
72
+ def pin(name, to: nil, preload: true, integrity: true)
31
73
  clear_cache
32
- @directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload)
74
+ @packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload, integrity: integrity)
33
75
  end
34
76
 
35
- def preloaded_module_paths(resolver:)
36
- cache_as(:preloaded_module_paths) do
37
- resolve_asset_paths(expanded_preloading_packages_and_directories, resolver: resolver).values
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)
80
+ end
81
+
82
+ # Returns an array of all the resolved module paths of the pinned packages. The `resolver` must respond to
83
+ # `path_to_asset`, such as `ActionController::Base.helpers` or `ApplicationController.helpers`. You'll want to use the
84
+ # resolver that has been configured for the `asset_host` you want these resolved paths to use. In case you need to
85
+ # resolve for different asset hosts, you can pass in a custom `cache_key` to vary the cache used by this method for
86
+ # the different cases.
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)
138
+ cache_as(cache_key) do
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
38
154
  end
39
155
  end
40
156
 
41
- def to_json(resolver:)
42
- cache_as(:json) do
43
- JSON.pretty_generate({ "imports" => resolve_asset_paths(expanded_packages_and_directories, resolver: resolver) })
157
+ # Returns a JSON hash (as a string) of all the resolved module paths of the pinned packages in the import map format.
158
+ # The `resolver` must respond to `path_to_asset`, such as `ActionController::Base.helpers` or
159
+ # `ApplicationController.helpers`. You'll want to use the resolver that has been configured for the `asset_host` you
160
+ # want these resolved paths to use. In case you need to resolve for different asset hosts, you can pass in a custom
161
+ # `cache_key` to vary the cache used by this method for the different cases.
162
+ def to_json(resolver:, cache_key: :json)
163
+ cache_as(cache_key) do
164
+ packages = expanded_packages_and_directories
165
+ map = build_import_map(packages, resolver: resolver)
166
+ JSON.pretty_generate(map)
44
167
  end
45
168
  end
46
169
 
@@ -56,7 +179,7 @@ class Importmap::Map
56
179
  Digest::SHA1.hexdigest(to_json(resolver: resolver).to_s)
57
180
  end
58
181
 
59
- # Returns an instance ActiveSupport::EventedFileUpdateChecker configured to clear the cache of the map
182
+ # Returns an instance of ActiveSupport::EventedFileUpdateChecker configured to clear the cache of the map
60
183
  # when the directories passed on initialization via `watches:` have changes. This is used in development
61
184
  # and test to ensure the map caches are reset when javascript files are changed.
62
185
  def cache_sweeper(watches: nil)
@@ -71,35 +194,78 @@ class Importmap::Map
71
194
  end
72
195
 
73
196
  private
74
- MappedDir = Struct.new(:dir, :path, :under, :preload, keyword_init: true)
75
- 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)
76
199
 
77
200
  def cache_as(name)
78
- if result = instance_variable_get("@cached_#{name}")
201
+ if result = @cache[name.to_s]
79
202
  result
80
203
  else
81
- instance_variable_set("@cached_#{name}", yield)
204
+ @cache[name.to_s] = yield
82
205
  end
83
206
  end
84
207
 
85
208
  def clear_cache
86
- @cached_json = nil
87
- @cached_preloaded_module_paths = nil
209
+ @cache.clear
210
+ end
211
+
212
+ def rescuable_asset_error?(error)
213
+ Rails.application.config.importmap.rescuable_asset_errors.any? { |e| error.is_a?(e) }
88
214
  end
89
215
 
90
216
  def resolve_asset_paths(paths, resolver:)
91
217
  paths.transform_values do |mapping|
92
- begin
93
- resolver.asset_path(mapping.path)
94
- rescue Sprockets::Rails::Helper::AssetNotFound
95
- Rails.logger.warn "Importmap skipped missing path: #{mapping.path}"
218
+ resolve_asset_path(mapping.path, resolver:)
219
+ end.compact
220
+ end
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}"
96
228
  nil
229
+ else
230
+ raise e
97
231
  end
98
- end.compact
232
+ end
99
233
  end
100
234
 
101
- def expanded_preloading_packages_and_directories
102
- expanded_packages_and_directories.select { |name, mapping| mapping.preload }
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
+
267
+ def expanded_preloading_packages_and_directories(entry_point:)
268
+ expanded_packages_and_directories.select { |name, mapping| mapping.preload.in?([true, false]) ? mapping.preload : (Array(mapping.preload) & Array(entry_point)).any? }
103
269
  end
104
270
 
105
271
  def expanded_packages_and_directories
@@ -114,22 +280,37 @@ class Importmap::Map
114
280
  module_name = module_name_from(module_filename, mapping)
115
281
  module_path = module_path_from(module_filename, mapping)
116
282
 
117
- paths[module_name] = MappedFile.new(name: module_name, path: module_path, preload: mapping.preload)
283
+ paths[module_name] = MappedFile.new(
284
+ name: module_name,
285
+ path: module_path,
286
+ preload: mapping.preload,
287
+ integrity: mapping.integrity
288
+ )
118
289
  end
119
290
  end
120
291
  end
121
292
  end
122
293
 
123
294
  def module_name_from(filename, mapping)
124
- [ mapping.under, filename.to_s.remove(filename.extname).remove(/\/?index$/).presence ].compact.join("/")
295
+ # Regex explanation:
296
+ # (?:\/|^) # Matches either / OR the start of the string
297
+ # index # Matches the word index
298
+ # $ # Matches the end of the string
299
+ #
300
+ # Sample matches
301
+ # index
302
+ # folder/index
303
+ index_regex = /(?:\/|^)index$/
304
+
305
+ [ mapping.under, filename.to_s.chomp(filename.extname).remove(index_regex).presence ].compact.join("/")
125
306
  end
126
307
 
127
308
  def module_path_from(filename, mapping)
128
- [ mapping.path || mapping.under, filename.to_s ].compact.join("/")
309
+ [ mapping.path || mapping.under, filename.to_s ].compact.reject(&:empty?).join("/")
129
310
  end
130
311
 
131
312
  def find_javascript_files_in_tree(path)
132
- Dir[path.join("**/*.js{,m}")].collect { |file| Pathname.new(file) }.select(&:file?)
313
+ Dir[path.join("**/*.js{,m}")].sort.collect { |file| Pathname.new(file) }.select(&:file?)
133
314
  end
134
315
 
135
316
  def absolute_root_of(path)