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.
@@ -1,6 +1,12 @@
1
1
  require "thor"
2
2
  require "importmap/packager"
3
+ require "importmap/package_graph"
4
+ require "importmap/vendored_graph"
3
5
  require "importmap/npm"
6
+ require "importmap/provider_chain"
7
+ require "importmap/batch_resolver"
8
+ require "importmap/integrity"
9
+ require "importmap/doctor"
4
10
 
5
11
  class Importmap::Commands < Thor
6
12
  include Thor::Actions
@@ -17,19 +23,24 @@ class Importmap::Commands < Thor
17
23
  option :minify, type: :boolean, desc: "Minify the vendored download with bun, esbuild or terser"
18
24
  option :lock, type: :boolean, desc: "Lock the pin at this version; update, pin and pristine leave it there until unlocked"
19
25
  option :force, type: :boolean, default: false, desc: "Re-pin locked packages, keeping each lock at the new version"
26
+ option :vendor, type: :boolean, default: false, desc: "Vendor the download even when it looks like it needs sibling files; converts a pin kept remote back"
27
+ option :integrity, type: :boolean, default: true, desc: "Write a subresource-integrity hash on a pin kept remote; --no-integrity skips the fetch that computes it"
20
28
  def pin(*packages)
21
- packages = without_locked(packages, lock: options[:lock], force: options[:force])
29
+ packages = resolve_latest_versions(without_locked(packages, lock: options[:lock], force: options[:force]))
22
30
  # jspm resolves a package together with its dependencies; --lock and
23
31
  # --no-lock are about the packages that were asked for, not those.
24
32
  requested = packages.map { |spec| packager.package_key_for(spec) }
25
33
 
26
- for_each_import_grouped_by_provider(packages, env: options[:env], from: options[:from]) do |package, url|
34
+ for_each_import_grouped_by_provider(packages, env: options[:env], from: options[:from], fallback: true) do |package, url|
27
35
  next if keep_locked_dependency(package, requested)
28
36
 
29
37
  pin_package(package, url, preload: options[:preload], remote: options[:remote], env: options[:env],
30
38
  minify: options[:minify], from: options[:from],
31
- lock: requested.include?(package) ? options[:lock] : nil)
39
+ lock: requested.include?(package) ? options[:lock] : nil,
40
+ vendor: requested.include?(package) && options[:vendor])
32
41
  end
42
+
43
+ exit 1 if skipped.any? || resolver.unresolved.any?
33
44
  end
34
45
 
35
46
  desc "lock [*PACKAGES]", "Lock packages at their pinned version"
@@ -60,6 +71,7 @@ class Importmap::Commands < Thor
60
71
  option :minify, type: :boolean, desc: "Minify every download; defaults to what each vendored file already is"
61
72
  def pristine
62
73
  packages = prepare_packages_with_versions
74
+ unrestored = []
63
75
 
64
76
  for_each_import_grouped_by_provider(packages, env: options[:env], from: options[:from]) do |package, url|
65
77
  if packager.remote_pin?(package)
@@ -67,14 +79,13 @@ class Importmap::Commands < Thor
67
79
  elsif (resolved = version_drift_of_locked(package, url))
68
80
  puts %(Skipping "#{package}" (locked at #{packager.pin_provenance(package)[:version]}, CDN resolved #{resolved}))
69
81
  else
70
- minify = options[:minify].nil? ? vendored_minified?(package) : options[:minify]
71
-
72
- puts %(Downloading "#{package}" to #{packager.vendor_path}/#{package}.js from #{url}#{" (minified)" if minify})
73
-
74
- pin_esm_run_dependencies packager.download(package, url, minify: minify), minify: minify
75
- record_provenance(package, url, minify) if provenance_changed?(package, url, minify)
82
+ unrestored << package unless restore_package(package, url)
76
83
  end
77
84
  end
85
+
86
+ # skipped: an esm.run bundle's dependency is pinned on the way, and one the
87
+ # CDN failed on is skipped the same way pin skips it.
88
+ exit 1 if unrestored.any? || skipped.any? || resolver.unresolved.any?
78
89
  end
79
90
 
80
91
  desc "json", "Show the full importmap in json"
@@ -149,14 +160,14 @@ class Importmap::Commands < Thor
149
160
  else
150
161
  keys = packages.any? ? requested_keys_for(packages, names) : outdated_keys_for(names)
151
162
 
152
- for_each_import_grouped_by_provider(keys, env: "production") do |package, url|
163
+ for_each_import_grouped_by_provider(keys, env: "production", fallback: true) do |package, url|
153
164
  next if keep_locked_dependency(package, keys)
154
165
 
155
166
  pin_package(package, url)
156
167
  end
157
168
  end
158
169
 
159
- exit 1 if unchecked_packages.any?
170
+ exit 1 if unchecked_packages.any? || skipped.any? || resolver.unresolved.any?
160
171
  end
161
172
 
162
173
  desc "packages", "Print out packages with version numbers"
@@ -164,7 +175,32 @@ class Importmap::Commands < Thor
164
175
  puts npm.packages_with_versions.map { |x| x.join(' ') }
165
176
  end
166
177
 
178
+ desc "doctor", "Check the importmap and the vendored files for problems"
179
+ option :online, type: :boolean, default: false, desc: "Also fetch every remote pin and check its integrity hash"
180
+ def doctor
181
+ require Rails.root.join("config/environment")
182
+
183
+ doctor = Importmap::Doctor.new(
184
+ importmap: Rails.application.importmap,
185
+ resolver: ActionController::Base.helpers,
186
+ root: Rails.root,
187
+ asset_paths: asset_paths,
188
+ online: options[:online]
189
+ )
190
+
191
+ doctor.diagnose.each { |finding| puts finding }
192
+ puts doctor.summary
193
+
194
+ exit 1 if doctor.errors?
195
+ end
196
+
167
197
  private
198
+ # Where a logical path is looked for on disk. Every engine adds its own, so
199
+ # a file a gem serves resolves here exactly as it does in a request.
200
+ def asset_paths
201
+ Rails.application.config.respond_to?(:assets) ? Rails.application.config.assets.paths : []
202
+ end
203
+
168
204
  def packager
169
205
  @packager ||= Importmap::Packager.new
170
206
  end
@@ -175,35 +211,121 @@ class Importmap::Commands < Thor
175
211
 
176
212
  # A lock outlives a rewrite unless the caller says otherwise, so update
177
213
  # and --force re-lock at the version they move to.
178
- def pin_package(package, url, preload: nil, remote: false, env: "production", minify: nil, from: nil, lock: nil)
214
+ def pin_package(package, url, preload: nil, remote: false, env: "production", minify: nil, from: nil, lock: nil, vendor: false)
179
215
  existing_options = packager.extract_existing_pin_options(package)[package] || {}
180
216
  preload = existing_options[:preload] if preload.nil?
181
217
  integrity = existing_options[:integrity]
182
218
  locked = lock.nil? ? packager.locked?(package) : lock
219
+ # A pin vendored on purpose stays vendored: without this an update would
220
+ # inspect the download again, refuse it again, and quietly undo --vendor.
221
+ vendor ||= packager.vendored?(package)
222
+ # A pin kept remote only because its file imports siblings is vendored
223
+ # again now that the whole graph can be. The reason its comment records
224
+ # is one this gem has since learned to answer, and without this an app
225
+ # that hit the check once would never see the fix arrive.
226
+ regraph = packager.remote_reason(package) == Importmap::PackageGraph::REASON
183
227
  existing_url = existing_options[:to] if existing_options[:to].to_s.match?(Importmap::Packager::REMOTE_URL_REGEXP)
184
228
 
185
- if existing_url
229
+ report_graph_shadowing(package)
230
+
231
+ if existing_url && !vendor && !regraph
186
232
  repin_remote_package(package, url, existing_url, preload, env: env, from: from, integrity: integrity, locked: locked)
233
+ elsif existing_url && from.nil? && packager.provider_for_url(existing_url).nil?
234
+ keep_custom_url(package, existing_url)
187
235
  elsif remote
188
236
  pin_remote_package(package, url, preload, integrity: integrity, locked: locked)
189
237
  else
190
- pin_vendored_package(package, url, preload, minify: minify, integrity: integrity, locked: locked)
238
+ pin_vendored_package(package, url, preload, minify: minify, integrity: integrity, locked: locked, vendor: vendor)
191
239
  end
192
240
  end
193
241
 
194
- def pin_vendored_package(package, url, preload, minify: nil, integrity: nil, locked: false)
242
+ # Nothing is said about a download until it has arrived and been found fit
243
+ # to serve on its own, so a package kept remote reports that and only that.
244
+ def pin_vendored_package(package, url, preload, minify: nil, integrity: nil, locked: false, vendor: false)
195
245
  minify = vendored_minified?(package) if minify.nil?
196
246
 
197
- puts %(Pinning "#{package}" to #{packager.vendor_path}/#{package}.js via download from #{url}#{" (minified)" if minify})
247
+ begin
248
+ dependencies = packager.download(package, url, minify: minify, force: vendor, graph: !vendor)
249
+ rescue Importmap::Packager::Unvendorable, Importmap::Packager::NotAnEsModule => refusal
250
+ return pin_remote_package(package, url, preload, integrity: integrity, locked: locked, kept_remote: refusal)
251
+ rescue Importmap::VendoredGraph::Occupied => occupied
252
+ # Nothing is written, including the pin: the app is asked to move its
253
+ # own directory rather than told afterwards that this gem took it.
254
+ return skip(package, occupied.message)
255
+ rescue Importmap::Packager::Error => error
256
+ # The CDN couldn't be read once its retries were spent. The pin is left
257
+ # exactly as it was: a package that vendors today must not turn into a
258
+ # remote pin — losing the files that make it work — because of a 503.
259
+ return skip(package, error.message)
260
+ end
261
+
262
+ graph = packager.last_graph
263
+
264
+ puts %(Pinning "#{package}" to #{packager.vendor_path}/#{package}.js via download from #{url}#{" (minified)" if minify}#{graph_note(graph)})
198
265
 
199
- dependencies = packager.download(package, url, minify: minify)
266
+ update_importmap_with_pin(package, packager.vendored_pin_for(package, url, preload, minify: minify, integrity: integrity, locked: locked, vendored: vendor))
267
+
268
+ # The line that maps the graph directory goes with the download: a
269
+ # package that stopped needing one takes its line and its files with it,
270
+ # or pin_all_from keeps mapping files nothing imports.
271
+ if graph
272
+ update_importmap_with_graph_pin(package, packager.graph_pin_for(package, url, preload))
273
+ else
274
+ packager.remove_graph(package)
275
+ end
200
276
 
201
- update_importmap_with_pin(package, packager.vendored_pin_for(package, url, preload, minify: minify, integrity: integrity, locked: locked))
202
277
  report_lock(package) if locked
203
278
 
204
279
  pin_esm_run_dependencies(dependencies, preload: preload, minify: minify)
205
280
  end
206
281
 
282
+ # A package that couldn't be pinned has said why on stdout; this is what
283
+ # makes the exit code say so too, or `pin x && git commit` commits a tree
284
+ # with no pin in it. Kept on the command rather than returned up through
285
+ # pin_package, because an esm.run bundle's dependencies are pinned two
286
+ # calls down and are skipped the same way.
287
+ def skip(package, reason)
288
+ puts %(Skipping "#{package}": #{reason})
289
+ skipped << package
290
+ end
291
+
292
+ def skipped
293
+ @skipped ||= []
294
+ end
295
+
296
+ # pristine restores what each pin already says, so a package vendored
297
+ # before the single-file check existed — or on purpose with --vendor — is
298
+ # downloaded again rather than converted, and only a pin that already maps
299
+ # a graph directory has one crawled again. pin is where both are decided.
300
+ #
301
+ # A package the CDN can no longer serve the way its pin describes stops
302
+ # that one package being restored, not the run: pristine is the repair
303
+ # command, and the packages after it in the batch still need repairing.
304
+ def restore_package(package, url)
305
+ minify = options[:minify].nil? ? vendored_minified?(package) : options[:minify]
306
+
307
+ puts %(Downloading "#{package}" to #{packager.vendor_path}/#{package}.js from #{url}#{" (minified)" if minify})
308
+
309
+ dependencies = packager.download(package, url, minify: minify, force: true,
310
+ graph: packager.vendored_graph(package).mapped?)
311
+
312
+ # A download that came back as one file — a --from that moved the package
313
+ # to a bundling CDN — takes its graph line with it, or pin_all_from is
314
+ # left mapping the directory that download just removed.
315
+ packager.remove_graph(package) unless packager.last_graph
316
+
317
+ pin_esm_run_dependencies dependencies, minify: minify
318
+ record_provenance(package, url, minify) if provenance_changed?(package, url, minify)
319
+
320
+ true
321
+ rescue Importmap::Packager::Unvendorable, Importmap::Packager::NotAnEsModule => refusal
322
+ puts %(Couldn't restore "#{package}": it #{refusal.message})
323
+ false
324
+ rescue Importmap::Packager::Error => error
325
+ puts %(Couldn't restore "#{package}": #{error.message})
326
+ false
327
+ end
328
+
207
329
  def lock_package(spec)
208
330
  package = packager.package_key_for(spec)
209
331
 
@@ -301,12 +423,20 @@ class Importmap::Commands < Thor
301
423
 
302
424
  # The registry knows a package by name and the import map by key: a pin of
303
425
  # "photoswipe/lightbox" is outdated when "photoswipe" is. A named update
304
- # re-pins the keys that were asked for, not the name the registry answered with.
426
+ # re-pins the keys that were asked for: a package name means every pin
427
+ # that carries it, the way outdated reports it and a bare update moves it,
428
+ # while "photoswipe/lightbox" means that one key.
305
429
  def requested_keys_for(specs, outdated_names)
306
- specs.map { |spec| packager.package_key_for(spec) }
430
+ specs.flat_map { |spec| keys_for(packager.package_key_for(spec)) }
307
431
  .select { |key| outdated_names.include?(packager.package_name_for(key)) }
308
432
  end
309
433
 
434
+ def keys_for(key)
435
+ return [ key ] unless key == packager.package_name_for(key)
436
+
437
+ versioned_keys_by_package[key] || [ key ]
438
+ end
439
+
310
440
  # An outdated package is outdated in every pin that carries it, so a bare
311
441
  # update re-pins those keys: "photoswipe" moving updates the app's
312
442
  # "photoswipe/lightbox" pin rather than appending a bare one beside it.
@@ -320,10 +450,12 @@ class Importmap::Commands < Thor
320
450
  # doesn't match its key (pin "buffer", to: ".../npm:jspm-core@..."); it is
321
451
  # still the only handle there is, so it goes through as itself.
322
452
  def outdated_keys_for(names)
323
- keys = packager.pinned_packages.select { |key| packager.pin_version(key) }
324
- .group_by { |key| packager.package_name_for(key) }
453
+ names.flat_map { |name| versioned_keys_by_package[name] || [ name ] }
454
+ end
325
455
 
326
- names.flat_map { |name| keys[name] || [ name ] }
456
+ def versioned_keys_by_package
457
+ packager.pinned_packages.select { |key| packager.pin_version(key) }
458
+ .group_by { |key| packager.package_name_for(key) }
327
459
  end
328
460
 
329
461
  def locked_pin_covering(name)
@@ -370,6 +502,7 @@ class Importmap::Commands < Thor
370
502
 
371
503
  update_importmap_with_pin(package, packager.vendored_pin_for(package, url, existing_options[:preload],
372
504
  minify: minify, integrity: existing_options[:integrity],
505
+ vendored: packager.vendored?(package),
373
506
  locked: packager.locked?(package)))
374
507
  end
375
508
 
@@ -387,25 +520,119 @@ class Importmap::Commands < Thor
387
520
  # A vendored package keeps coming from the CDN its pin comment names, the
388
521
  # way a remote pin keeps its provider, unless --from says otherwise.
389
522
  # Packages that aren't pinned yet resolve from jspm.
390
- def for_each_import_grouped_by_provider(packages, env:, from: nil, &block)
391
- packages.group_by { |spec| from || vendored_provider_for(spec) || "jspm" }.each do |provider, group|
392
- for_each_import(group, env: env, from: provider, &block)
523
+ #
524
+ # +fallback+ lets the group that named no CDN try the rest of the chain
525
+ # when jspm can't build it, which is what pin and update want. pristine
526
+ # doesn't: it restores what each pin already says, and a pin that moved to
527
+ # another CDN would be a rewrite, not a restore.
528
+ def for_each_import_grouped_by_provider(packages, env:, from: nil, fallback: false, &block)
529
+ packages.group_by { |spec| from || vendored_provider_for(spec) || Importmap::ProviderChain::DEFAULT }.each do |provider, group|
530
+ resolver.each_import(group, env: env, from: provider,
531
+ fallback: fallback && from.nil? && Importmap::ProviderChain.default?(provider), &block)
393
532
  end
394
533
  end
395
534
 
535
+ # Shared by every group of a single run, so the exit code can ask it once
536
+ # whether anything was left unresolved.
537
+ def resolver
538
+ @resolver ||= Importmap::BatchResolver.new(packager, on_miss: method(:handle_package_not_found))
539
+ end
540
+
541
+ # A spec with no version means the latest, and the registry is what knows
542
+ # which that is: a CDN answers with the latest it has got round to indexing,
543
+ # which lags npm by anything from hours to a major release. Resolving it up
544
+ # front also pins the version for the whole chain, so falling back to
545
+ # another CDN can't quietly land the app on a different one. A registry
546
+ # that can't be reached leaves the spec alone, and the CDN chooses as before.
547
+ def resolve_latest_versions(specs)
548
+ specs.map do |spec|
549
+ name, version, _subpath = spec.to_s.match(Importmap::Packager::PACKAGE_SPEC_REGEXP)&.captures
550
+ next spec if version || name.nil?
551
+
552
+ if (latest = npm.latest_version(name))
553
+ puts %(Resolved "#{name}" to #{latest} from the npm registry)
554
+ packager.package_spec_for(spec, "@#{latest}")
555
+ else
556
+ spec
557
+ end
558
+ end
559
+ end
560
+
561
+ # A subpath pin that names no CDN of its own comes from wherever its
562
+ # package's pin came from: pdfjs-dist and pdfjs-dist/build/pdf.worker.min.mjs
563
+ # were pinned together, and jspm, the default, can't resolve either.
564
+ # A pin answers for itself first and only then for its subpaths: the URL on
565
+ # pin "photoswipe/lightbox" says more about where that file comes from than
566
+ # its package's pin does. Asking the package first grouped the two together,
567
+ # and one spec the CDN can't answer for fails the whole batch.
396
568
  def vendored_provider_for(spec)
397
- packager.pin_provenance(packager.package_key_for(spec))&.dig(:provider)
569
+ key = packager.package_key_for(spec)
570
+ name = packager.package_name_for(key)
571
+
572
+ return packager.pin_provenance(key)&.dig(:provider) || kept_remote_provider(key) if key == name
573
+
574
+ provider_of_pin(key) || provider_of_pin(name)
575
+ end
576
+
577
+ # A pin the single-file check kept remote records its CDN only in its URL:
578
+ # the comment's provider slot is taken by the reason it was kept. Asking the
579
+ # URL keeps such a pin on its own CDN when it is re-pinned — and, now that a
580
+ # chunked package can be vendored whole, when it converts to a download.
581
+ # A pin the app made remote itself carries no reason and is left to
582
+ # repin_remote_package, which resolves it from its URL as it always did.
583
+ def kept_remote_provider(key)
584
+ return unless packager.remote_reason(key)
585
+
586
+ packager.provider_for_url(packager.extract_existing_pin_options(key).dig(key, :to))
587
+ end
588
+
589
+ # A vendored pin records its CDN in the version comment; a remote one
590
+ # carries it in the URL and, unlocked, has no comment at all. Only a subpath
591
+ # asks this, and a bare package pin above stops at its comment: reading the
592
+ # URL of every pin would change where a plain remote pin resolves its
593
+ # dependencies from, which is not this lookup's business.
594
+ def provider_of_pin(package)
595
+ packager.pin_provenance(package)&.dig(:provider) ||
596
+ packager.provider_for_url(packager.extract_existing_pin_options(package).dig(package, :to))
398
597
  end
399
598
 
400
- def pin_remote_package(package, url, preload, integrity: nil, locked: false)
401
- puts %(Pinning "#{package}" to #{url})
599
+ # +kept_remote+ is the Packager's refusal to vendor a download it just
600
+ # fetched its reasons, and the hash of the bytes it already has; without
601
+ # it the reason the pin already records is carried over, so update and a
602
+ # plain pin don't drop it.
603
+ def pin_remote_package(package, url, preload, integrity: nil, locked: false, kept_remote: nil)
604
+ if compute_integrity?
605
+ integrity = remote_integrity_for(url, integrity, kept_remote&.integrity)
606
+ elsif packager.integrity_hash?(package)
607
+ puts %(Dropping the integrity hash on "#{package}" (--no-integrity))
608
+ end
609
+
610
+ notes = +""
611
+ notes << %( (kept remote: #{kept_remote.reasons.to_sentence})) if kept_remote
612
+ notes << %( (integrity #{integrity})) if Importmap::Integrity.hash?(integrity)
613
+
614
+ puts %(Pinning "#{package}" to #{url}#{notes})
615
+
616
+ remote = kept_remote&.reasons&.first || packager.remote_reason(package)
402
617
 
403
618
  packager.remove_existing_package_file(package)
619
+ packager.remove_graph(package)
404
620
 
405
- update_importmap_with_pin(package, packager.pin_for(package, url, preloads: preload, integrity: integrity, locked: locked))
621
+ update_importmap_with_pin(package, packager.pin_for(package, url, preloads: preload, integrity: integrity,
622
+ locked: locked, remote: remote))
406
623
  report_lock(package) if locked
407
624
  end
408
625
 
626
+ # A URL on a host no provider answers for is the app's own choice, and the
627
+ # branch above skips it — but only while `vendor` is false. --vendor is
628
+ # about overriding the check that refuses a download, not about moving a
629
+ # pin to another source, so it lands here instead of vendoring whatever the
630
+ # spec happened to resolve to. Naming a CDN with --from is how a pin is
631
+ # moved on purpose, and that still goes through repin_remote_package.
632
+ def keep_custom_url(package, existing_url)
633
+ puts %(Skipping "#{package}" pinned to custom URL #{existing_url} (--vendor doesn't move a pin to another source; pass --from to choose one))
634
+ end
635
+
409
636
  def repin_remote_package(package, url, existing_url, preload, env:, from: nil, integrity: nil, locked: false)
410
637
  # `url` was already resolved from the requested CDN, so an explicit
411
638
  # --from moves the pin instead of being overruled by its current one.
@@ -424,6 +651,31 @@ class Importmap::Commands < Thor
424
651
  end
425
652
  end
426
653
 
654
+ # Read from the command's own options rather than threaded through every
655
+ # pin path: update, pristine and an esm.run bundle's dependency pins all
656
+ # reach pin_remote_package too, and a keyword one of them forgets to pass
657
+ # silently hashes a pin the app asked not to hash. Only pin declares the
658
+ # flag, so anything else sees nil and hashes.
659
+ def compute_integrity?
660
+ options["integrity"] != false
661
+ end
662
+
663
+ # A remote pin is the one place an app trusts a CDN at runtime, so the pin
664
+ # records the hash of what was resolved and the browser checks the bytes it
665
+ # gets against it. integrity: false is an app's decision and stays; a hash
666
+ # the line already carried was computed for the old URL and was dropped
667
+ # when the line was read, so nil here means "hash this". +computed+ is the
668
+ # hash a download that was just refused already carries — the file isn't
669
+ # fetched again for it.
670
+ def remote_integrity_for(url, integrity, computed = nil)
671
+ return integrity if integrity == false
672
+
673
+ computed || Importmap::Integrity.for(packager.fetch_remote(url))
674
+ rescue Importmap::Packager::Error => error
675
+ puts %(Couldn't hash #{url} (#{error.message}); pinning it without an integrity hash)
676
+ nil
677
+ end
678
+
427
679
  def resolve_url_from_provider(package, reference_url, provider, env:)
428
680
  version = packager.extract_package_version_from(reference_url)
429
681
  response = packager.import(packager.package_spec_for(package, version), env: env, from: provider)
@@ -434,8 +686,52 @@ class Importmap::Commands < Thor
434
686
  nil
435
687
  end
436
688
 
689
+ # Importmap::Map expands a directory over the packages, so a key some other
690
+ # package's graph already maps resolves to that file whatever the pin about
691
+ # to be written says. Say so rather than write a line that does nothing.
692
+ def report_graph_shadowing(package)
693
+ mapped = Importmap::VendoredGraph.mapping_for(importmap_source, package)
694
+
695
+ puts %(Note: the graph of "#{mapped}" already maps "#{package}", and a mapped directory wins over a pin) if mapped
696
+ end
697
+
698
+ # Two directories mapping one package write their files under the same
699
+ # prefix, so at two versions the app gets one of them for every file the
700
+ # two share, and which one depends on the order of the lines.
701
+ def report_graph_conflict(pin)
702
+ directory, under, version = Importmap::VendoredGraph.mappings_in(pin).first
703
+ conflict = Importmap::VendoredGraph.conflict_in(importmap_source, under: under, version: version, except: directory)
704
+
705
+ puts %(Note: #{conflict.first} already maps "#{under}" at #{conflict.last}; a file both carry resolves to one of them) if conflict
706
+ end
707
+
708
+ def importmap_source
709
+ File.exist?("config/importmap.rb") ? File.read("config/importmap.rb") : ""
710
+ end
711
+
712
+ # How many files came down with the entry, so an app can see that a chunked
713
+ # package was vendored whole rather than as the one file that would 404.
714
+ def graph_note(graph)
715
+ %( (with #{graph.size} sibling #{"file".pluralize(graph.size)})) if graph && graph.size.positive?
716
+ end
717
+
718
+ def update_importmap_with_graph_pin(package, pin)
719
+ report_graph_conflict(pin)
720
+
721
+ if packager.vendored_graph(package).mapped?
722
+ gsub_file("config/importmap.rb", packager.vendored_graph(package).line_regexp, pin, verbose: false)
723
+ else
724
+ append_to_file("config/importmap.rb", "#{pin}\n", verbose: false)
725
+ end
726
+
727
+ packager.reload!
728
+ end
729
+
437
730
  def update_importmap_with_pin(package, pin)
438
731
  new_pin = "#{pin}\n"
732
+ # Two esm.run bundles can need one dependency: skipped on the first
733
+ # bundle's turn and pinned on the second's, it is in the tree after all.
734
+ skipped.delete(package)
439
735
 
440
736
  if packager.packaged?(package)
441
737
  gsub_file("config/importmap.rb", Importmap::Map.pin_line_regexp_for(package), pin, verbose: false)
@@ -446,8 +742,13 @@ class Importmap::Commands < Thor
446
742
  packager.reload!
447
743
  end
448
744
 
449
- def handle_package_not_found(packages, from)
450
- puts "Couldn't find any packages in #{packages.inspect} on #{from}"
745
+ # jspm answers 401 with its generator's reason in the body — the subpath a
746
+ # dependency doesn't export, the module it couldn't find and that is the
747
+ # one thing that tells an app developer whether to try another CDN.
748
+ def handle_package_not_found(packages, from, reason: packager.last_import_error)
749
+ detail = Importmap::ProviderChain.tidy_reason(reason)
750
+
751
+ puts "Couldn't find any packages in #{packages.inspect} on #{from}#{" (#{detail})" if detail}"
451
752
  end
452
753
 
453
754
  def remove_line_from_file(path, pattern)