importmap-plus 1.1.1 → 2.0.0

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.
@@ -0,0 +1,331 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "pathname"
4
+ require "active_support/core_ext/array/conversions"
5
+ require "active_support/core_ext/string/inflections"
6
+ require "importmap/http_retries"
7
+ require "importmap/import_scanner"
8
+ require "importmap/integrity"
9
+ require "importmap/module_inspector"
10
+ require "importmap/packager"
11
+ require "importmap/package_graph"
12
+ require "importmap/vendored_graph"
13
+
14
+ # Everything that can be wrong with an app's import map without anything
15
+ # saying so until a page fails in the browser: a pin whose file isn't there, a
16
+ # vendored file importing a bare specifier nobody pinned, a vendored file that
17
+ # still needs the siblings it was downloaded beside, a CommonJS bundle a CDN
18
+ # handed back, two pins serving one file, a file left in vendor/javascript that
19
+ # nothing maps.
20
+ #
21
+ # It reports; it never fixes. `pin`, `unpin` and `pin --vendor` are what fix
22
+ # things, and a doctor that edited config/importmap.rb would be a doctor nobody
23
+ # could run in CI.
24
+ #
25
+ # Offline by default: every check but the last reads the map, the asset paths
26
+ # and the files on disk. +online+ adds the one check that can't be made without
27
+ # the network — that a remote pin is still served, and still serves the bytes
28
+ # its integrity hash was computed from.
29
+ class Importmap::Doctor
30
+ include Importmap::HttpRetries
31
+
32
+ Error = Class.new(StandardError)
33
+ HTTPError = Class.new(Error)
34
+
35
+ # Wide enough for "warning" plus the space that separates every level from
36
+ # its message, so the messages line up under each other.
37
+ LEVEL_WIDTH = 8 # :nodoc:
38
+
39
+ Finding = Struct.new(:level, :message) do # :nodoc:
40
+ def to_s
41
+ "#{level.to_s.ljust(LEVEL_WIDTH)} #{message}"
42
+ end
43
+ end
44
+
45
+ # +path+ is the logical asset path or the URL the key maps to, +file+ the
46
+ # file on disk that serves it, or nil when no asset path holds one.
47
+ Entry = Struct.new(:name, :path, :integrity, :file, keyword_init: true) # :nodoc:
48
+
49
+ # Where `pin --vendor` and a vendored graph write, and so the only directory
50
+ # whose contents this gem is entitled to have an opinion about.
51
+ VENDOR_PATH = "vendor/javascript".freeze # :nodoc:
52
+
53
+ # Importmap::Map's directory glob is `**/*.js{,m}` — .js and .jsm, not .mjs.
54
+ # This one takes .mjs too, on purpose: a .mjs under a pin_all_from directory
55
+ # is precisely the file the map can never see, and saying so is the point.
56
+ VENDORED_GLOB = "**/*.{js,jsm,mjs}".freeze # :nodoc:
57
+
58
+ # A specifier the import map has to define. Anything the browser resolves
59
+ # against the importing file's own URL, or fetches outright, is not one.
60
+ SCHEME_REGEXP = %r{\A[a-zA-Z][a-zA-Z0-9+\-.]*:}.freeze # :nodoc:
61
+
62
+ def initialize(importmap:, resolver:, root:, asset_paths: [], online: false)
63
+ @importmap = importmap
64
+ @resolver = resolver
65
+ @root = Pathname.new(root)
66
+ @asset_paths = Array(asset_paths).map { |path| Pathname.new(path) }
67
+ @online = online
68
+ end
69
+
70
+ # Every finding, errors before warnings within each check and the checks in
71
+ # the order they are defined below.
72
+ def diagnose
73
+ @diagnose ||= [
74
+ *unresolvable_pins,
75
+ *unpinned_specifiers,
76
+ *stray_relative_imports,
77
+ *non_modules,
78
+ *unserved_vendored_files,
79
+ *keys_sharing_a_path,
80
+ *packages_vendored_twice,
81
+ *(@online ? unreachable_remote_pins : [])
82
+ ]
83
+ end
84
+
85
+ def errors?
86
+ diagnose.any? { |finding| finding.level == :error }
87
+ end
88
+
89
+ def summary
90
+ errors, warnings = diagnose.partition { |finding| finding.level == :error }
91
+
92
+ "#{errors.size} #{"error".pluralize(errors.size)}, #{warnings.size} #{"warning".pluralize(warnings.size)}"
93
+ end
94
+
95
+ private
96
+ def unresolvable_pins
97
+ entries.reject { |entry| remote?(entry.path) }.filter_map do |entry|
98
+ error(%(pin "#{entry.name}" → #{entry.path}: no such asset)) unless resolves?(entry.path)
99
+ end
100
+ end
101
+
102
+ def unpinned_specifiers
103
+ scannable.flat_map do |entry|
104
+ imports_in(entry.file).filter_map do |import|
105
+ next unless bare?(import.specifier)
106
+ next if pinned?(import.specifier)
107
+
108
+ error(%(#{relative(entry.file)} imports "#{import.specifier}", which isn't pinned))
109
+ end
110
+ end
111
+ end
112
+
113
+ # A vendored file's relative imports were rewritten to the keys of the
114
+ # files vendored beside it, so one left over points at a file that isn't
115
+ # there: the download predates the fork learning to vendor a file graph, or
116
+ # the graph directory has since been removed.
117
+ def stray_relative_imports
118
+ vendored.flat_map do |entry|
119
+ imports_in(entry.file).filter_map do |import|
120
+ next unless relative?(import.specifier)
121
+ next if sibling_of(entry.file, import.specifier)&.file?
122
+
123
+ error(%(#{relative(entry.file)} imports "#{import.specifier}" by relative path — ) +
124
+ %(run bin/importmap pin #{vendored_package_of(entry.file) || entry.name} to vendor its files))
125
+ end
126
+ end
127
+ end
128
+
129
+ def non_modules
130
+ vendored.filter_map do |entry|
131
+ next if Importmap::ModuleInspector.new(source_of(entry.file)).es_module?
132
+
133
+ error("#{relative(entry.file)} isn't an ES module")
134
+ end
135
+ end
136
+
137
+ def unserved_vendored_files
138
+ (vendored_files - entries.filter_map(&:file)).map do |file|
139
+ warning("#{relative(file)} isn't pinned by anything")
140
+ end
141
+ end
142
+
143
+ # Two keys on one file are two modules in the browser, each with its own
144
+ # state, and whichever one a package imports is the one the other half of
145
+ # the app isn't using.
146
+ def keys_sharing_a_path
147
+ entries.group_by(&:path).filter_map do |path, sharing|
148
+ next unless sharing.size > 1
149
+
150
+ names = sharing.map { |entry| %("#{entry.name}") }
151
+ warning("#{names.to_sentence} #{all_or_both(names)} resolve to #{path}")
152
+ end
153
+ end
154
+
155
+ def packages_vendored_twice
156
+ vendored_versions.group_by { |_file, package, _version| package }.filter_map do |package, group|
157
+ versions = group.map { |_file, _package, version| version }
158
+ next if versions.uniq.size < 2
159
+
160
+ files = group.map { |file, _package, _version| relative(file) }
161
+ warning("#{files.to_sentence} #{all_or_both(files)} vendor #{package}, at #{versions.to_sentence}")
162
+ end
163
+ end
164
+
165
+ def unreachable_remote_pins
166
+ entries.select { |entry| remote?(entry.path) }.filter_map { |entry| remote_finding_for(entry) }
167
+ end
168
+
169
+ def remote_finding_for(entry)
170
+ response = with_retries("fetching #{entry.path}") { Net::HTTP.get_response(URI(entry.path)) }
171
+
172
+ if response.code != "200"
173
+ error(%(pin "#{entry.name}" → #{entry.path}: the CDN answered #{response.code}))
174
+ elsif Importmap::Integrity.hash?(entry.integrity) && entry.integrity != Importmap::Integrity.for(response.body)
175
+ error(%(pin "#{entry.name}" → #{entry.path}: integrity doesn't match what the CDN served))
176
+ end
177
+ rescue HTTPError => e
178
+ # The reason with_retries gives already names the URL, so the finding
179
+ # doesn't name it a second time.
180
+ error(%(pin "#{entry.name}": #{e.message}))
181
+ end
182
+
183
+ def entries
184
+ @entries ||= @importmap.each_expanded_package.map do |name, mapping|
185
+ Entry.new(name: name, path: mapping.path, integrity: mapping.integrity, file: file_for(mapping.path))
186
+ end
187
+ end
188
+
189
+ # The files this gem may read: the app's own, not a file an engine serves
190
+ # out of its gem, which no `bin/importmap` command can do anything about.
191
+ # One file serving two keys is scanned once.
192
+ def scannable
193
+ @scannable ||= entries.select { |entry| entry.file && under?(entry.file, @root) }.uniq(&:file)
194
+ end
195
+
196
+ def vendored
197
+ @vendored ||= scannable.select { |entry| under?(entry.file, @root.join(VENDOR_PATH)) }
198
+ end
199
+
200
+ def vendored_files
201
+ @vendored_files ||= Pathname.glob(@root.join(VENDOR_PATH, VENDORED_GLOB)).select(&:file?).sort
202
+ end
203
+
204
+ # The package and version every vendored file's header names, read out of
205
+ # the CDN URL it was downloaded from — the npm identity, which is what says
206
+ # two pins hold one package, rather than the key, which is whatever the app
207
+ # typed. A file with no header, or from a CDN whose URLs don't spell the
208
+ # package out, simply isn't in the answer.
209
+ def vendored_versions
210
+ @vendored_versions ||= vendored_files.filter_map do |file|
211
+ url = header_url_of(file)
212
+ package, version = Importmap::PackageGraph.package_and_version_for(url) if url
213
+
214
+ [ file, package, version ] if package && version
215
+ end
216
+ end
217
+
218
+ # The files a graph vendored carry no header of their own — the entry they
219
+ # were downloaded beside does, and Importmap::Packager names their
220
+ # directory after that entry's file. So a sibling asks the entry what
221
+ # package it belongs to, and the hint names something `bin/importmap pin`
222
+ # can act on rather than the key of the one file that went missing.
223
+ def vendored_package_of(file)
224
+ vendored_packages[file] || vendored_packages[graph_entry_for(file)]
225
+ end
226
+
227
+ def vendored_packages
228
+ @vendored_packages ||= vendored_versions.to_h { |file, package, _version| [ file, package ] }
229
+ end
230
+
231
+ def graph_entry_for(file)
232
+ segments = file.relative_path_from(@root.join(VENDOR_PATH)).each_filename.to_a
233
+ return if segments.size < 2
234
+
235
+ @root.join(VENDOR_PATH, "#{segments.first}.js")
236
+ end
237
+
238
+ def header_url_of(file)
239
+ File.open(file, "rb") { |io| io.gets.to_s }[Importmap::VendoredGraph::DOWNLOADED_FROM_REGEXP, 1]
240
+ end
241
+
242
+ def file_for(path)
243
+ return if remote?(path)
244
+
245
+ @asset_paths.lazy.map { |root| root.join(path) }.find(&:file?)
246
+ end
247
+
248
+ def resolves?(path)
249
+ @resolver.path_to_asset(path)
250
+ true
251
+ rescue => error
252
+ raise error unless rescuable_asset_error?(error)
253
+
254
+ false
255
+ end
256
+
257
+ # The same list Importmap::Map rescues on the request path, so a pin the
258
+ # map would quietly skip is the pin this reports.
259
+ def rescuable_asset_error?(error)
260
+ Rails.application.config.importmap.rescuable_asset_errors.any? { |klass| error.is_a?(klass) }
261
+ end
262
+
263
+ def imports_in(file)
264
+ Importmap::ImportScanner.new(source_of(file)).imports
265
+ end
266
+
267
+ # Read as bytes and scrubbed: a vendored file this gem didn't write, or
268
+ # wrote before it learned to ask CDNs for a body it can read, can hold a
269
+ # sequence no UTF-8 regexp will match without raising, and one such file
270
+ # must not stop every other check.
271
+ def source_of(file)
272
+ @sources ||= {}
273
+ @sources[file] ||= File.binread(file).force_encoding(Encoding::UTF_8).scrub
274
+ end
275
+
276
+ # A specifier the map resolves: a key it defines outright, a key ending in
277
+ # "/" that covers everything under it, or — leniently — a key the specifier
278
+ # is a subpath of. That last one is not what a browser does, which needs
279
+ # the trailing-slash key. It is here because the alternative is an error on
280
+ # every subpath of every package vendored with its file graph, whose keys
281
+ # this gem writes one by one, and a check that cries wolf is a check nobody
282
+ # runs. A missed subpath still fails in the browser exactly as loudly as it
283
+ # did before anyone ran the doctor.
284
+ def pinned?(specifier)
285
+ keys.any? do |key|
286
+ key == specifier || (key.end_with?("/") ? specifier.start_with?(key) : specifier.start_with?("#{key}/"))
287
+ end
288
+ end
289
+
290
+ def keys
291
+ @keys ||= entries.map(&:name)
292
+ end
293
+
294
+ def bare?(specifier)
295
+ !specifier.empty? && !relative?(specifier) && !specifier.start_with?("/") && !specifier.match?(SCHEME_REGEXP)
296
+ end
297
+
298
+ def relative?(specifier)
299
+ specifier.start_with?("./", "../")
300
+ end
301
+
302
+ def remote?(path)
303
+ path.match?(Importmap::Packager::REMOTE_URL_REGEXP)
304
+ end
305
+
306
+ def sibling_of(file, specifier)
307
+ file.dirname.join(specifier.sub(/[?#].*\z/, ""))
308
+ rescue ArgumentError
309
+ nil
310
+ end
311
+
312
+ def under?(file, directory)
313
+ file.to_s.start_with?("#{directory}/")
314
+ end
315
+
316
+ def relative(file)
317
+ file.relative_path_from(@root)
318
+ end
319
+
320
+ def all_or_both(items)
321
+ items.size > 2 ? "all" : "both"
322
+ end
323
+
324
+ def error(message)
325
+ Finding.new(:error, message)
326
+ end
327
+
328
+ def warning(message)
329
+ Finding.new(:warning, message)
330
+ end
331
+ end
@@ -0,0 +1,50 @@
1
+ # The modulepreload links of javascript_importmap_tags, sent ahead of the
2
+ # response as 103 Early Hints.
3
+ #
4
+ # A link in the body can only be acted on once the browser has parsed that far
5
+ # into the HTML, so the whole module graph waits on the response. The same list
6
+ # sent as a Link header before the response starts lets the fetches begin while
7
+ # the app is still rendering — which is what Rails' own javascript_include_tag
8
+ # and stylesheet_link_tag already do for their assets.
9
+ #
10
+ # `request.send_early_hints` is `env["rack.early_hints"]&.call(links)`: a no-op
11
+ # unless the server put the callable there (Puma does with `early_hints true`),
12
+ # so on a server without it this costs one joined string and nothing else.
13
+ module Importmap::EarlyHints
14
+ class << self
15
+ # `view` is the view rendering the tags — the request and response are read
16
+ # off it the way ActionView's own send_preload_links_header reads them, so a
17
+ # context that has neither is left alone. `packages` is a
18
+ # preloaded_module_packages hash, resolved asset path to the package behind
19
+ # it, which makes the hinted set exactly the tags' set.
20
+ def send_modulepreload_links(view, packages)
21
+ return if packages.empty? || !enabled? || sending?(view)
22
+
23
+ request = view.request if view.respond_to?(:request)
24
+ return unless request.respond_to?(:send_early_hints)
25
+
26
+ request.send_early_hints("link" => link_header_for(packages.keys))
27
+ end
28
+
29
+ private
30
+ # No integrity parameter: browsers don't honour one on a Link header, and
31
+ # the modulepreload tag in the body still carries it, which is where the
32
+ # hash has to match anyway.
33
+ def link_header_for(paths)
34
+ paths.collect { |path| "<#{path}>; rel=modulepreload" }.join(", ")
35
+ end
36
+
37
+ # A 103 is a response of its own, written to the socket before the real
38
+ # one. Under `render stream: true` the layout renders while the 200 is
39
+ # already going out, and a hint sent then lands in the middle of the body.
40
+ def sending?(view)
41
+ return false unless view.respond_to?(:response)
42
+
43
+ (response = view.response).respond_to?(:sending?) && response.sending?
44
+ end
45
+
46
+ def enabled?
47
+ Rails.application.config.importmap.early_hints
48
+ end
49
+ end
50
+ end
@@ -1,4 +1,5 @@
1
1
  require "importmap/map"
2
+ require "importmap/early_hints"
2
3
 
3
4
  # Use Rails.application.importmap to access the map
4
5
  Rails::Application.send(:attr_accessor, :importmap)
@@ -10,6 +11,8 @@ module Importmap
10
11
  config.importmap.sweep_cache = Rails.env.development? || Rails.env.test?
11
12
  config.importmap.cache_sweepers = []
12
13
  config.importmap.rescuable_asset_errors = []
14
+ config.importmap.preload_strategy = :all
15
+ config.importmap.early_hints = true
13
16
 
14
17
  config.autoload_once_paths = %W( #{root}/app/helpers #{root}/app/controllers )
15
18
 
@@ -0,0 +1,118 @@
1
+ require "uri"
2
+ require "json"
3
+
4
+ # Everything this gem knows about jsDelivr's bundling endpoint
5
+ # (https://www.jsdelivr.com/esm): one minified ESM file per package, with its
6
+ # dependencies referenced as /npm/dep@ver/+esm.
7
+ #
8
+ # It is the one provider whose URLs a CDN host can't identify — an esm.run
9
+ # bundle and a plain jsDelivr file are both served from cdn.jsdelivr.net — and
10
+ # the one whose downloads have to be rewritten before they can be vendored, so
11
+ # both facts live here rather than in Importmap::Packager, which asks this
12
+ # class the two questions it has: is this that provider, and is this one of its
13
+ # URLs.
14
+ #
15
+ # Nothing here writes a file or reads config/importmap.rb. The version lookup
16
+ # is the one request, and it goes out through the Packager so a jsDelivr that
17
+ # rate-limits or resets gets the same bounded retries as every other request
18
+ # this gem makes, and fails as the same Importmap::Packager::HTTPError.
19
+ class Importmap::EsmRun
20
+ PROVIDER = "esm.run".freeze # :nodoc:
21
+ CDN = "https://cdn.jsdelivr.net/npm/".freeze # :nodoc:
22
+ URL_REGEXP = %r{\Ahttps://cdn\.jsdelivr\.net/npm/.+/\+esm\z}.freeze # :nodoc:
23
+ # A bundle's own imports: `from"/npm/dep@1.2.3/+esm"`, `import"…"`,
24
+ # `import("…")`, `export … from"…"`. Anchored on the keyword so an ordinary
25
+ # string that happens to look like a bundle URL is left alone. What it does
26
+ # not do is parse JavaScript, so the same text inside a string or a comment
27
+ # would still be rewritten — a jsDelivr bundle is esbuild output whose only
28
+ # surviving comment is the banner, and a root-relative /npm/ URL is
29
+ # meaningless anywhere but in one of its own imports.
30
+ IMPORT_REGEXP =
31
+ %r{((?:\bfrom|\bimport)\s*\(?\s*)(["'])/npm/((?:@[^/"'@]+/)?[^/"'@]+)@([^/"']+)((?:/[^"']*?)?)/\+esm\2}.freeze # :nodoc:
32
+
33
+ # The jsDelivr data API versions are resolved through. Also readable and
34
+ # writable as Importmap::Packager.esm_run_resolver, which is where an app
35
+ # that points this at a mirror has always set it.
36
+ singleton_class.attr_accessor :resolver
37
+ self.resolver = URI("https://data.jsdelivr.com/v1/packages/npm/")
38
+
39
+ class << self
40
+ def provider?(provider)
41
+ provider.to_s == PROVIDER
42
+ end
43
+
44
+ def url?(url)
45
+ url.to_s.match?(URL_REGEXP)
46
+ end
47
+
48
+ def url_for(name, version, subpath = nil)
49
+ "#{CDN}#{name}@#{version}#{subpath}/+esm"
50
+ end
51
+
52
+ # Turns import "/npm/dep@1.2.3/+esm" into import "dep" so the bundle
53
+ # resolves through the import map, and lists what it needs pinned as
54
+ # [ [ package, url ], … ].
55
+ def rewrite_imports(source)
56
+ dependencies = {}
57
+ versions = Hash.new { |hash, key| hash[key] = [] }
58
+
59
+ rewritten = source.gsub(IMPORT_REGEXP) do
60
+ keyword, quote, name, version, subpath = $1, $2, $3, $4, $5.to_s
61
+ key = "#{name}#{subpath}"
62
+ dependencies[key] ||= url_for(name, version, subpath)
63
+ versions[key] << version unless versions[key].include?(version)
64
+ "#{keyword}#{quote}#{name}#{subpath}#{quote}"
65
+ end
66
+
67
+ warn_about_conflicting_versions(versions)
68
+
69
+ [ rewritten, dependencies.to_a ]
70
+ end
71
+
72
+ private
73
+ # An import map maps a bare specifier to one file, so a bundle that
74
+ # imports the same package at two versions can only get the first one it
75
+ # asked for. Say so rather than pick silently.
76
+ def warn_about_conflicting_versions(versions)
77
+ versions.each do |key, seen|
78
+ next if seen.one?
79
+
80
+ warn %(#{key} is imported at #{seen.join(", ")} by this bundle; pinning @#{seen.first}, an import map holds one version)
81
+ end
82
+ end
83
+ end
84
+
85
+ def initialize(packager)
86
+ @packager = packager
87
+ end
88
+
89
+ # The import map Importmap::Packager#import answers with, built from the
90
+ # version jsDelivr resolves each spec to; nil when it hasn't got one of them,
91
+ # because a bundle URL for a version nobody published is a 404 at download.
92
+ def imports(specs)
93
+ imports = Array(specs).to_h do |spec|
94
+ name, requested, subpath = spec.to_s.match(Importmap::Packager::PACKAGE_SPEC_REGEXP)&.captures
95
+ raise Importmap::Packager::Error, "Can't parse package spec #{spec.inspect}" unless name
96
+
97
+ version = resolve_version(name, requested)
98
+ return nil unless version
99
+
100
+ [ "#{name}#{subpath}", self.class.url_for(name, version, subpath) ]
101
+ end
102
+
103
+ { imports: imports }
104
+ end
105
+
106
+ private
107
+ def resolve_version(name, requested)
108
+ uri = self.class.resolver.dup
109
+ uri.path += "#{name}/resolved"
110
+ uri.query = "specifier=#{URI.encode_www_form_component(requested)}" if requested
111
+
112
+ body = @packager.fetch_remote(uri, allow_missing: true, description: "resolving #{uri}")
113
+
114
+ body && JSON.parse(body)["version"]
115
+ rescue JSON::ParserError
116
+ raise Importmap::Packager::HTTPError, "Unexpected response from #{uri}"
117
+ end
118
+ end
@@ -0,0 +1,126 @@
1
+ require "pathname"
2
+ require "set"
3
+ require "importmap/import_scanner"
4
+
5
+ # Which pins an entry point actually reaches, read out of the files the map
6
+ # already lists. An import map says what every key resolves to; it says nothing
7
+ # about which keys a given page loads, and so `preload: true` preloads
8
+ # everything on every page. The graph is the missing half: `application.js`
9
+ # imports some of the map, those files import more of it, and the rest is only
10
+ # ever reached through `import()` — or not at all.
11
+ #
12
+ # Static imports only. A dynamic `import()` is the app saying "later", and
13
+ # preloading its target undoes the deferral the app asked for. That makes this
14
+ # a walk of what the browser fetches while linking the entry point, which is
15
+ # exactly what a modulepreload link is for.
16
+ #
17
+ # Nothing here reaches the network. The files are the ones the asset pipeline
18
+ # already serves, read once per Map cache generation and dropped with the rest
19
+ # of the cache when the sweeper sees a .js change.
20
+ class Importmap::Graph
21
+ # A path the browser fetches itself rather than one the asset pipeline
22
+ # serves — `https://…`, and the protocol-relative `//…` a hand-written pin
23
+ # can hold. Importmap::Packager::REMOTE_URL_REGEXP says the same thing, but
24
+ # requiring the CLI onto the request path to borrow it would be a poor trade.
25
+ REMOTE_PATH_REGEXP = %r{\A(?:[a-zA-Z][a-zA-Z0-9+\-.]*:)?//}.freeze # :nodoc:
26
+
27
+ def initialize(map, roots: [])
28
+ @map = map
29
+ @roots = Array(roots).map { |root| Pathname.new(root) }
30
+ end
31
+
32
+ # The keys reachable from +entry_points+ through static imports, the entry
33
+ # points themselves included. An entry point the map doesn't define, a pin
34
+ # whose file isn't on any asset path and a remote pin are all leaves: they
35
+ # contribute themselves and nothing further.
36
+ def reachable_from(entry_points)
37
+ queue = Array(entry_points).select { |key| entries.key?(key) }
38
+ reached = Set.new(queue)
39
+
40
+ while (key = queue.shift)
41
+ edges_from(key).each { |target| queue << target if reached.add?(target) }
42
+ end
43
+
44
+ reached
45
+ end
46
+
47
+ private
48
+ def entries
49
+ @entries ||= @map.each_expanded_package.to_h
50
+ end
51
+
52
+ # The key a path belongs to, for resolving a relative import back into the
53
+ # map. Two keys can name one file; the first one drawn wins, and since both
54
+ # resolve to the same asset path the preload set can't tell them apart
55
+ # anyway.
56
+ def keys_by_path
57
+ @keys_by_path ||= entries.each_with_object({}) { |(key, mapping), keys| keys[mapping.path] ||= key }
58
+ end
59
+
60
+ def edges_from(key)
61
+ @edges ||= {}
62
+ @edges[key] ||= compute_edges(entries[key])
63
+ end
64
+
65
+ def compute_edges(mapping)
66
+ file = file_for(mapping.path)
67
+ return [] unless file
68
+
69
+ Importmap::ImportScanner.new(source_of(file)).imports.filter_map { |import|
70
+ key_for(import.specifier, mapping.path) if import.kind == :static
71
+ }.uniq
72
+ end
73
+
74
+ def file_for(path)
75
+ return if path.match?(REMOTE_PATH_REGEXP)
76
+
77
+ @roots.lazy.map { |root| root.join(path) }.find(&:file?)
78
+ end
79
+
80
+ # Read as bytes and scrubbed, like Importmap::Doctor: a vendored file this
81
+ # gem didn't write can hold a sequence no UTF-8 regexp will match without
82
+ # raising, and one such file must not take a page's preloads with it.
83
+ def source_of(file)
84
+ File.binread(file).force_encoding(Encoding::UTF_8).scrub
85
+ end
86
+
87
+ # A relative specifier is resolved against the importing key's own path and
88
+ # matched back to the key holding that path — the browser resolves it
89
+ # against the importing module's URL, and the map's paths are what those
90
+ # URLs are built from. Anything else names a key outright, a path a key
91
+ # maps to, or a subpath of a package pinned as one file.
92
+ def key_for(specifier, importer_path)
93
+ return if specifier.empty?
94
+
95
+ if relative?(specifier)
96
+ keys_by_path[resolved_path(specifier, importer_path)]
97
+ else
98
+ exact_key(specifier) || enclosing_key(specifier)
99
+ end
100
+ end
101
+
102
+ def relative?(specifier)
103
+ specifier.start_with?("./", "../")
104
+ end
105
+
106
+ def resolved_path(specifier, importer_path)
107
+ Pathname.new(importer_path).dirname.join(specifier.sub(/[?#].*\z/, "")).cleanpath.to_s
108
+ rescue ArgumentError
109
+ nil
110
+ end
111
+
112
+ def exact_key(specifier)
113
+ entries.key?(specifier) ? specifier : keys_by_path[specifier]
114
+ end
115
+
116
+ # `pin "foo/", to: "foo/"` covers everything under it, and a subpath of a
117
+ # package pinned as one file — which the browser can't resolve, and
118
+ # Importmap::Doctor reports — is counted as reaching that package, because
119
+ # preloading one file too many costs a request and preloading one too few
120
+ # costs the waterfall this whole mechanism exists to avoid.
121
+ def enclosing_key(specifier)
122
+ entries.keys.select { |key|
123
+ key.end_with?("/") ? specifier.start_with?(key) : specifier.start_with?("#{key}/")
124
+ }.max_by(&:length)
125
+ end
126
+ end