Skip to content

Commit 19df09b

Browse files
committed
Expose integrity in the module preload tags
1 parent 516e4ef commit 19df09b

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="/rich_text.js">
46+
<link rel="modulepreload" href="/rich_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
@@ -152,6 +152,133 @@ def setup
152152
assert_not_equal set_two, @importmap.preloaded_module_paths(resolver: ApplicationController.helpers, cache_key: "2").to_s
153153
end
154154

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

0 commit comments

Comments
 (0)