Skip to content

Commit 7f237bc

Browse files
committed
Expose integrity in the module preload tags
1 parent 1c42eb4 commit 7f237bc

File tree

4 files changed

+195
-6
lines changed

4 files changed

+195
-6
lines changed

app/helpers/importmap/importmap_tags_helper.rb

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,23 @@ def javascript_import_module_tag(*module_names)
2525
# (defaults to Rails.application.importmap), such that they'll be fetched
2626
# in advance by browsers supporting this link type (https://caniuse.com/?search=modulepreload).
2727
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 }] }
2931
end
3032

3133
# Link tag(s) for preloading the JavaScript module residing in `*paths`. Will return one link tag per path element.
3234
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, {}] }
3636
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
3747
end

lib/importmap/map.rb

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,60 @@ def pin_all_from(dir, under: nil, to: nil, preload: true)
4141
# resolve for different asset hosts, you can pass in a custom `cache_key` to vary the cache used by this method for
4242
# the different cases.
4343
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)
4494
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:).to_h do |_, package|
96+
[resolve_asset_path(package.path, resolver: resolver), package]
97+
end.delete_if { |key| key.nil? }
4698
end
4799
end
48100

test/importmap_tags_helper_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def content_security_policy_nonce
4343
assert_dom_equal(
4444
%(
4545
<link rel="modulepreload" href="https://cdn.skypack.dev/md5">
46-
<link rel="modulepreload" href="/rick_text.js">
46+
<link rel="modulepreload" href="/rick_text.js" integrity="sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb">
4747
),
4848
javascript_importmap_module_preload_tags
4949
)

test/importmap_test.rb

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,133 @@ def setup
150150
assert_not_equal set_two, @importmap.preloaded_module_paths(resolver: ApplicationController.helpers, cache_key: "2").to_s
151151
end
152152

153+
test "preloaded_module_packages returns hash of resolved paths to packages when no entry_point specified" do
154+
packages = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers)
155+
156+
md5 = packages["https://cdn.skypack.dev/md5"]
157+
assert md5, "Should include md5 package"
158+
assert_equal "md5", md5.name
159+
assert_equal "https://cdn.skypack.dev/md5", md5.path
160+
assert_equal true, md5.preload
161+
162+
goodbye_controller_path = packages.keys.find { |path| path.include?("goodbye_controller") }
163+
assert goodbye_controller_path, "Should include goodbye_controller package"
164+
assert_equal "controllers/goodbye_controller", packages[goodbye_controller_path].name
165+
assert_equal true, packages[goodbye_controller_path].preload
166+
167+
leaflet = packages["https://cdn.skypack.dev/leaflet"]
168+
assert leaflet, "Should include leaflet package"
169+
assert_equal "leaflet", leaflet.name
170+
assert_equal "https://cdn.skypack.dev/leaflet", leaflet.path
171+
assert_equal 'application', leaflet.preload
172+
173+
chartkick = packages["https://cdn.skypack.dev/chartkick"]
174+
assert chartkick, "Should include chartkick package"
175+
assert_equal "chartkick", chartkick.name
176+
assert_equal ['application', 'alternate'], chartkick.preload
177+
178+
application_path = packages.keys.find { |path| path.include?("application") }
179+
assert_nil application_path, "Should not include application package (preload: false)"
180+
181+
tinymce_path = packages.keys.find { |path| path.include?("tinymce") }
182+
assert_nil tinymce_path, "Should not include tinymce package (preload: 'alternate')"
183+
end
184+
185+
test "preloaded_module_packages returns hash based on single entry_point provided" do
186+
packages = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, entry_point: "alternate")
187+
188+
tinymce = packages["https://cdn.skypack.dev/tinymce"]
189+
assert tinymce, "Should include tinymce package for alternate entry point"
190+
assert_equal "tinyMCE", tinymce.name
191+
assert_equal "https://cdn.skypack.dev/tinymce", tinymce.path
192+
assert_equal 'alternate', tinymce.preload
193+
194+
# Should include packages for multiple entry points (chartkick preloads for both 'application' and 'alternate')
195+
chartkick = packages["https://cdn.skypack.dev/chartkick"]
196+
assert chartkick, "Should include chartkick package"
197+
assert_equal "chartkick", chartkick.name
198+
assert_equal ['application', 'alternate'], chartkick.preload
199+
200+
# Should include always-preloaded packages
201+
md5 = packages["https://cdn.skypack.dev/md5"]
202+
assert md5, "Should include md5 package (always preloaded)"
203+
204+
leaflet_path = packages.keys.find { |path| path.include?("leaflet") }
205+
assert_nil leaflet_path, "Should not include leaflet package (preload: 'application' only)"
206+
end
207+
208+
test "preloaded_module_packages returns hash based on multiple entry_points provided" do
209+
packages = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, entry_point: ["application", "alternate"])
210+
211+
leaflet = packages["https://cdn.skypack.dev/leaflet"]
212+
assert leaflet, "Should include leaflet package for application entry point"
213+
214+
# Should include packages for 'alternate' entry point
215+
tinymce = packages["https://cdn.skypack.dev/tinymce"]
216+
assert tinymce, "Should include tinymce package for alternate entry point"
217+
218+
# Should include packages for multiple entry points
219+
chartkick = packages["https://cdn.skypack.dev/chartkick"]
220+
assert chartkick, "Should include chartkick package for both entry points"
221+
222+
# Should include always-preloaded packages
223+
md5 = packages["https://cdn.skypack.dev/md5"]
224+
assert md5, "Should include md5 package (always preloaded)"
225+
226+
application_path = packages.keys.find { |path| path.include?("application") }
227+
assert_nil application_path, "Should not include application package (preload: false)"
228+
end
229+
230+
test "preloaded_module_packages includes package integrity when present" do
231+
# Create a new importmap with a preloaded package that has integrity
232+
importmap = Importmap::Map.new.tap do |map|
233+
map.pin "editor", to: "rich_text.js", preload: true, integrity: "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb"
234+
end
235+
236+
packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers)
237+
238+
editor_path = packages.keys.find { |path| path.include?("rich_text") }
239+
assert editor_path, "Should include editor package"
240+
assert_equal "editor", packages[editor_path].name
241+
assert_equal "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb", packages[editor_path].integrity
242+
end
243+
244+
test "preloaded_module_packages uses custom cache_key" do
245+
set_one = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "1").to_s
246+
247+
ActionController::Base.asset_host = "http://assets.example.com"
248+
249+
set_two = @importmap.preloaded_module_packages(resolver: ActionController::Base.helpers, cache_key: "2").to_s
250+
251+
assert_not_equal set_one, set_two
252+
ensure
253+
ActionController::Base.asset_host = nil
254+
end
255+
256+
test "preloaded_module_packages caches reset" do
257+
set_one = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "1").to_s
258+
set_two = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "2").to_s
259+
260+
@importmap.pin "something", to: "https://cdn.example.com/somewhere.js", preload: true
261+
262+
assert_not_equal set_one, @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "1").to_s
263+
assert_not_equal set_two, @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "2").to_s
264+
end
265+
266+
test "preloaded_module_packages handles missing assets gracefully" do
267+
importmap = Importmap::Map.new.tap do |map|
268+
map.pin "existing", to: "application.js", preload: true
269+
map.pin "missing", to: "nonexistent.js", preload: true
270+
end
271+
272+
packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers)
273+
274+
assert_equal 1, packages.size
275+
276+
existing_path = packages.keys.find { |path| path&.include?("application") }
277+
assert existing_path, "Should include existing asset"
278+
end
279+
153280
private
154281
def generate_importmap_json
155282
@generate_importmap_json ||= JSON.parse @importmap.to_json(resolver: ApplicationController.helpers)

0 commit comments

Comments
 (0)