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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c325b5de05deaf38ca9ed4f671d5c676b36f726ab4e3079e616dc964bdac00b8
4
- data.tar.gz: c11ca96640558bf7e4f3e294139ed80b9a893239811e4afa879024268401c90d
3
+ metadata.gz: a89ba9297f9e04e1f3a892ea319d509ce9300789f648327c3b4b1cf72c0951d7
4
+ data.tar.gz: c088e5ab5732c9f132bfa63a60d2a7d9243ac85690670e1a040e3658fc1fb091
5
5
  SHA512:
6
- metadata.gz: a69a963a3506522598a3b0cecb6c8b95c7e4dd275a2fe1cf748f7f5b181bbae62f490f22dc066b42adaf0071dc54e24d940309b2b31ee4b971365aec89ff8a68
7
- data.tar.gz: 3ff45f8c8e2ca16ef2f26d4d18438a870bcf50a12c63f29b44a58bf334a9404e3009f4544201f81d1fe7e2f862cad5e15858af5b05a14d7fb97894f2ef972638
6
+ metadata.gz: 81f2d28ee6c2d724917e338451d4a0f2baef86feb1bcc5477feca8114f13213787d9164b6befc8e7e02cf24e5ec0f810e13d05195be4e27c689bc70db5fd9311
7
+ data.tar.gz: 3cbd693b24887409203b8f75294798c3ee4b08dec82a90eb404de2a24e7ba7f273d4babd48a72c3a1608f8f263b4804b27dcfbf624edbb69252ea8ea6b346d63
data/CHANGELOG.md CHANGED
@@ -1,5 +1,356 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0
4
+
5
+ ### Added
6
+
7
+ - **`pin` resolves the version on the npm registry, then falls back from jspm
8
+ to esm.run to jsDelivr.** jspm is the default CDN and was the only one `pin`
9
+ asked, so a package its generator can't build — `mermaid@10.6.0` fails on a
10
+ cytoscape subpath, `@mui/material@5.15.0` on a module it can't find — ended
11
+ in `Couldn't find any packages`, and a bare `pin foo` took whatever version
12
+ jspm had indexed, which lags npm. A package that names no CDN of its own is
13
+ now asked of each in turn, and its version is settled against the registry
14
+ before any of them is asked:
15
+
16
+ ```
17
+ $ bin/importmap pin mermaid
18
+ Resolved "mermaid" to 10.6.0 from the npm registry
19
+ jspm couldn't resolve "mermaid@10.6.0" (No './dist/cytoscape.umd.js' exports subpath defined in cytoscape@3.34.3); trying esm.run
20
+ Pinning "mermaid" to vendor/javascript/mermaid.js via download from https://cdn.jsdelivr.net/npm/mermaid@10.6.0/+esm
21
+ ```
22
+ ```ruby
23
+ pin "mermaid" # @10.6.0 (esm.run)
24
+ ```
25
+
26
+ The pin comment already records a CDN that isn't jspm, so `update` and
27
+ `pristine` stay on esm.run from then on with no new state anywhere. An
28
+ explicit `--from`, and a provider a pin already names, are choices somebody
29
+ made: those are asked once and never fall back. When no CDN has the package,
30
+ each one's reason is printed and the summary names all three.
31
+ - **A download that isn't an ES module is kept remote instead of vendored.**
32
+ The CDNs that serve a package's own `dist` file hand back the UMD bundle
33
+ plenty of packages still publish; vendored into an import map it runs and
34
+ exports nothing, so `import x from "pkg"` fails to link in the browser and
35
+ nowhere else. `pin google-libphonenumber --from jsdelivr` now keeps the pin
36
+ remote and records `(remote: not an ES module)`. `--vendor` downloads it
37
+ anyway, and the default `pin` never sees it, since jspm converts the package.
38
+ - **The CDN's own reason reaches the terminal.** jspm answers 401 with its
39
+ generator's message in the body, which `pin` discarded: `Couldn't find any
40
+ packages in ["mermaid@10.6.0"] on jspm` said nothing about what went wrong.
41
+ That message is now printed with the sentence, whichever CDN was asked.
42
+
43
+ - **`pin` keeps a package remote when its file can't stand alone, and says
44
+ why.** A vendored package is one file served under a digested asset path,
45
+ but plenty of packages ship a file that imports a sibling by relative path,
46
+ spawns a `Worker`, reads `import.meta.url` or fetches a `.wasm` binary —
47
+ every one of those 404s in the browser, and importmap-rails vendors it
48
+ anyway. `pin` now reads the download before writing anything to
49
+ `vendor/javascript`; a file that needs more than itself is pinned to its CDN
50
+ URL and the reason goes on the pin:
51
+
52
+ ```
53
+ $ bin/importmap pin fflate@0.8.2
54
+ Pinning "fflate" to https://ga.jspm.io/npm:fflate@0.8.2/esm/browser.js (kept remote: workers)
55
+ ```
56
+ ```ruby
57
+ pin "fflate", to: "https://ga.jspm.io/npm:fflate@0.8.2/esm/browser.js" # @0.8.2 (remote: workers)
58
+ ```
59
+
60
+ The pin then behaves like any other remote pin — `pin` and `update`
61
+ re-resolve it from the same CDN and keep the reason, `pristine` skips it.
62
+ `pin --vendor` downloads the package anyway and records `(vendored)` on the
63
+ pin, so a later `update` doesn't undo the override; it also converts a pin
64
+ that was kept remote back to a download. Nothing an app already vendored is
65
+ rewritten on its own — `pristine` redownloads those pins as it always has —
66
+ but the next `pin` or `update` that touches one re-resolves it, and a package
67
+ whose file can't stand alone converts to a remote pin then. That is the fix
68
+ arriving, not a surprise: the vendored file it replaces was already 404ing
69
+ for its siblings. pdf.js is the package most apps will see this on — see
70
+ the upgrading page for what to expect and how `--vendor` puts it back.
71
+
72
+ - **A pin that stays remote carries a subresource-integrity hash.** A vendored
73
+ file is served by the app; a remote pin is fetched from a CDN on every page
74
+ load with nothing checking the bytes, and importmap-rails never wrote an
75
+ `integrity:` value for one — the hash had to be computed by hand and redone
76
+ on every update. `pin --remote`, and a package [kept remote] because its file
77
+ can't stand alone, now fetch the URL they just resolved, hash it and write it
78
+ with the pin:
79
+
80
+ ```
81
+ $ bin/importmap pin md5@2.2.0 --remote
82
+ Pinning "md5" to https://ga.jspm.io/npm:md5@2.2.0/md5.js (integrity sha384-+wqk6m3DPZ6mVMgVZlXnGgDjDY2skGEZ3U9tBnRHiPJXLRLKBmYkKlX2urz9T61b)
83
+ ```
84
+ ```ruby
85
+ pin "md5", to: "https://ga.jspm.io/npm:md5@2.2.0/md5.js", integrity: "sha384-+wqk6m3DPZ6mVMgVZlXnGgDjDY2skGEZ3U9tBnRHiPJXLRLKBmYkKlX2urz9T61b"
86
+ ```
87
+
88
+ The hash is `sha384` of the bytes the CDN served, computed here rather than
89
+ asked of any one CDN, so jspm, esm.run, jsDelivr, unpkg, esm.sh and skypack
90
+ are all covered by one code path. `update` and a later `pin` fetch the new
91
+ URL and rewrite the hash, so a pin never carries the hash of a file it no
92
+ longer points at — where before an explicit hash was simply dropped.
93
+ `enable_integrity!` in `config/importmap.rb` is still what puts the value in
94
+ the import map and on the preload link; `--no-integrity` skips the fetch for
95
+ a run, and `integrity: false` on a pin stays off for good. Vendored downloads
96
+ are unaffected: `integrity: true`, the default, already computes theirs
97
+ through the asset pipeline.
98
+
99
+ [kept remote]: https://importmap-plus.zoolutions.llc/docs/pinning
100
+
101
+ - **`pin` vendors a package's whole file graph, so a chunked package no longer
102
+ needs a CDN at runtime.** A package whose entry imports a sibling by relative
103
+ path — `@popperjs/core`, `date-fns`, `lodash-es`, and every package built by
104
+ a bundler that splits chunks — could not be vendored: the browser resolves
105
+ `./enums.js` against a digested asset path, and neither Propshaft nor
106
+ Sprockets rewrites `import` statements. Those packages were [kept remote].
107
+ `pin` now downloads the closed set of files the entry reaches, rewrites every
108
+ relative specifier to a bare key, and maps the directory with one
109
+ `pin_all_from` line:
110
+
111
+ ```
112
+ $ bin/importmap pin @popperjs/core@2.11.8
113
+ Pinning "@popperjs/core" to vendor/javascript/@popperjs/core.js via download from https://ga.jspm.io/npm:@popperjs/core@2.11.8/lib/index.js (with 47 sibling files)
114
+ ```
115
+ ```ruby
116
+ pin "@popperjs/core", to: "@popperjs--core.js" # @2.11.8
117
+ pin_all_from "vendor/javascript/@popperjs--core", under: "@popperjs/core", to: "@popperjs--core" # @2.11.8 (graph of @popperjs/core)
118
+ ```
119
+
120
+ The entry keeps the flat file and the plain comment it always had, so
121
+ `update`, `outdated`, `lock` and `pristine` read it exactly as before, and a
122
+ `config/importmap.rb` written this way still parses under importmap-rails.
123
+ Bare specifiers are untouched, and a file that is another pin's own entry is
124
+ rewritten to that pin's key rather than copied, so the browser evaluates each
125
+ module once. (Two pins of one package do each carry their own copy of a chunk
126
+ they share; both write it under the same key, so one of the copies is what
127
+ every importer gets and the other is dead weight.) `unpin` takes the directory and the line with the pin,
128
+ `pristine` rebuilds the directory, `pin --minify` minifies every file in it,
129
+ and `pin --vendor` downloads the entry on its own and drops the directory and
130
+ line it had. A directory the import map doesn't map as one of ours is the
131
+ app's: `pin` says so, writes nothing rather than renaming it away, and exits
132
+ non-zero — as it does when a CDN fails partway through a crawl, where the pin
133
+ and the files it had are left exactly as they were.
134
+
135
+ Only jspm, jsDelivr and unpkg are crawled — their URLs say where a package's
136
+ directory ends. A graph that can't be taken over whole (a relative path that
137
+ climbs out of the package, a sibling the CDN hasn't got, a sibling that isn't
138
+ JavaScript, two files that would collapse to one key) keeps the whole package
139
+ remote, as does a download that also spawns a worker, reads
140
+ `import.meta.url`, computes an `import()` or names a `.wasm` file. A pin
141
+ importmap-plus had kept remote for its relative imports is converted back to
142
+ a download by the next `pin` or `update`, on the CDN its URL names.
143
+
144
+ Two things `pin` says out loud rather than doing quietly: a key some other
145
+ package's graph already maps (a directory wins over a pin, so the pin would
146
+ do nothing), and a second directory mapping a package one already maps at
147
+ another version (a file they share resolves to one of them).
148
+
149
+ - **`bin/importmap doctor` checks an app's import map and vendored files,
150
+ offline.** `audit` and `outdated` ask the npm registry about your packages;
151
+ nothing asked whether the map still matched the files in the repo. A pin
152
+ whose file had gone missing, a vendored file importing a bare specifier
153
+ nobody pinned, a vendored file still importing the siblings it was downloaded
154
+ beside, a CommonJS bundle a CDN handed back — each of those said so in the
155
+ browser and nowhere else. `doctor` boots the app and reports them:
156
+
157
+ ```
158
+ $ bin/importmap doctor
159
+ error pin "not_there" → nowhere.js: no such asset
160
+ error vendor/javascript/shoelace.js imports "lit/decorators.js", which isn't pinned
161
+ error vendor/javascript/popper.js imports "./enums.js" by relative path — run bin/importmap pin @popperjs/core to vendor its files
162
+ warning vendor/javascript/old-lib.js isn't pinned by anything
163
+ warning "@hotwired/turbo" and "@hotwired/turbo-rails" both resolve to turbo.min.js
164
+ 3 errors, 2 warnings
165
+ ```
166
+
167
+ Errors exit 1, so it belongs in CI beside `audit` and `outdated`; warnings —
168
+ a file nothing serves, including a `.mjs` that `pin_all_from` never picks up,
169
+ and two keys on one file or one package vendored at two versions — leave the
170
+ exit status alone. It reports and never edits: `pin`, `unpin` and `pristine`
171
+ are what fix what it finds.
172
+
173
+ `--online` adds the one check that needs the network, fetching every remote
174
+ pin to report one the CDN no longer serves and an `integrity:` hash that
175
+ doesn't match the bytes it does serve. Every other check reads only the map,
176
+ the asset paths and the files on disk, so the default is fast and can't fail
177
+ because a CDN is having a bad afternoon.
178
+
179
+ - **`config.importmap.preload_strategy = :reachable` preloads what the entry
180
+ point actually reaches.** Every pin with `preload: true` gets a modulepreload
181
+ link on every page, so a package the app only loads with `import()` is
182
+ fetched up front anyway unless someone writes `preload: false` on it — and on
183
+ everything it depends on, and keeps that list right as the package changes.
184
+ An app on this gem carried the comment "imported only by apexcharts.js, which
185
+ is itself lazy. Without it, 1.1 MB was preloaded on every page."
186
+
187
+ The import graph already knows. `app/javascript`, `vendor/javascript` and
188
+ every `pin_all_from` directory are files on disk, and their `import`
189
+ statements name pin keys:
190
+
191
+ ```ruby
192
+ # config/application.rb
193
+ config.importmap.preload_strategy = :reachable
194
+ ```
195
+
196
+ `javascript_importmap_tags "application"` now emits a link only for the pins
197
+ `application` reaches through **static** imports, however deep. A dynamic
198
+ `import()` is the lazy boundary and contributes nothing — preloading its
199
+ target is the deferral the app asked for, undone. A pin naming an entry point
200
+ (`preload: "admin"`) is preloaded whether or not the graph reaches it, which
201
+ is the escape hatch for a specifier no regex can see; `preload: false` stays
202
+ off either way.
203
+
204
+ Nothing new happens on the request path: the files are the ones the asset
205
+ pipeline already serves, read once per import map cache generation and
206
+ dropped by the same sweeper that drops the rendered map when a `.js` file
207
+ changes. The default `:all` — upstream's behaviour, one link per
208
+ `preload: true` pin — is unchanged and never opens a file.
209
+
210
+ - **`javascript_importmap_tags` sends its modulepreload links as 103 Early
211
+ Hints.** A modulepreload link is markup, so the browser can only act on it
212
+ once the HTML has streamed far enough to be parsed — the whole module graph
213
+ waits on the response. The same list now goes out ahead of it as a `Link`
214
+ header, and the fetches start while the app is still rendering:
215
+
216
+ ```
217
+ HTTP/1.1 103 Early Hints
218
+ Link: </assets/application-abc.js>; rel=modulepreload, </assets/@hotwired--stimulus-def.js>; rel=modulepreload
219
+ ```
220
+
221
+ Exactly the modules the tags preload, so `preload: false`, an entry point's
222
+ own list and `preload_strategy = :reachable` all narrow the hinted set with
223
+ the tags. No `integrity` parameter: browsers don't honour one on a `Link`
224
+ header, and the tag in the body still carries it.
225
+
226
+ This is what Rails' own `javascript_include_tag` and `stylesheet_link_tag`
227
+ already do, and it needs what they need — a server that puts
228
+ `rack.early_hints` in the env, which Puma does with `early_hints true`. On a
229
+ server without it, `send_early_hints` is a no-op and nothing changes. Off
230
+ with `config.importmap.early_hints = false`.
231
+
232
+ ### Changed
233
+
234
+ - **Everything this gem knows about esm.run lives in `Importmap::EsmRun`.**
235
+ The provider name, the bundle URL shapes, the rewrite that turns a bundle's
236
+ `/npm/dep@ver/+esm` imports into bare specifiers and the jsDelivr version
237
+ lookup were nine things in `Importmap::Packager`, which had grown to the
238
+ 800-line ceiling with nowhere to put the next addition. Behaviour is
239
+ unchanged and no documented setting moved: `Importmap::Packager.esm_run_resolver`
240
+ still reads and writes the resolver, now on the new class. Only the `:nodoc:`
241
+ constants `Packager::ESM_RUN_*` are gone, as `Importmap::EsmRun::PROVIDER`,
242
+ `::CDN`, `::URL_REGEXP` and `::IMPORT_REGEXP`.
243
+
244
+ ### Fixed
245
+
246
+ - **One package a CDN can't resolve no longer blocks — or silently moves — the
247
+ rest of the batch.** ([#32](https://github.com/zoolutions/importmap-plus/issues/32))
248
+ A CDN answers a batch of packages as a whole, so `bin/importmap update` with
249
+ three outdated packages reported `Couldn't find any packages in
250
+ ["cheap-ruler", "mapbox-gl", "mermaid"] on jspm`, updated nothing and exited
251
+ 0, because jspm's generator can't build `mermaid`. Commenting out the one
252
+ package let the other two through. A refused batch of more than one package
253
+ is now asked for one package at a time, along the path each would have taken
254
+ alone:
255
+
256
+ ```
257
+ $ bin/importmap pin md5@2.2.0 mermaid@10.6.0
258
+ jspm couldn't resolve "md5@2.2.0", "mermaid@10.6.0" (No './dist/cytoscape.umd.js' exports subpath defined in cytoscape@3.34.3); asking for each on its own
259
+ Pinning "md5" to vendor/javascript/md5.js via download from https://ga.jspm.io/npm:md5@2.2.0/md5.js
260
+ jspm couldn't resolve "mermaid@10.6.0" (No './dist/cytoscape.umd.js' exports subpath defined in cytoscape@3.34.3); trying esm.run
261
+ Pinning "mermaid" to vendor/javascript/mermaid.js via download from https://cdn.jsdelivr.net/npm/mermaid@10.6.0/+esm
262
+ ```
263
+ ```ruby
264
+ pin "md5" # @2.2.0
265
+ pin "mermaid" # @10.6.0 (esm.run)
266
+ ```
267
+
268
+ Only the package the CDN refused travels the rest of the chain, so a healthy
269
+ package is never re-pinned from another CDN — and never has its provenance
270
+ comment rewritten — because a sibling failed. An explicit `--from`, or a
271
+ provider a pin records, still asks that one CDN and only that one, now once
272
+ per package. The batch stays the fast path: it is split only when it is
273
+ refused and holds more than one package.
274
+
275
+ `pin`, `update` and `pristine` now **exit 1** when a package was left
276
+ unresolved, so `bin/importmap update && git commit` can no longer commit an
277
+ import map that quietly missed a package. The packages that did resolve are
278
+ still written.
279
+ - **A CDN that fails mid-crawl leaves the pin alone.** Vendoring a graph makes
280
+ one request per file — 250 of them for `date-fns` — so a 503 that outlives
281
+ the retries is far likelier than it was for a single download. `pin` and
282
+ `pristine` report it (`Skipping "date-fns": Unexpected response code (503)`)
283
+ and change nothing, rather than taking it for "this package can't be
284
+ vendored" and converting a working pin to a remote one, which would delete
285
+ the very files that make it work.
286
+ - **`pristine` reports a package it can't restore and carries on.** It is the
287
+ repair command, and a pin whose graph the CDN no longer serves the way the
288
+ pin describes now raises where nothing used to — unrescued, that ended the
289
+ whole run with a backtrace and left every package after it unrestored. Each
290
+ one that fails is reported (`Couldn't restore "pdfjs-dist": it can't be
291
+ vendored as a single file (workers)`), the rest are restored, and the command
292
+ exits non-zero to say it didn't do all of it — as it does when a dependency
293
+ of an esm.run bundle, pinned on the way, had to be skipped.
294
+ - **A download the CDN encoded in a way Net::HTTP can't undo is fetched
295
+ again.** jspm answers some files with `content-encoding: br` whatever the
296
+ request advertises, and Net::HTTP decompresses gzip and deflate only:
297
+ `@popperjs/core@2.11.8/lib/utils/computeAutoPlacement.js` arrived as brotli
298
+ bytes, which read as invalid UTF-8 and took the source inspection down with
299
+ `ArgumentError: invalid byte sequence in UTF-8`. `pin` now repeats that one
300
+ request asking for an unencoded body. It doesn't ask up front: supplying an
301
+ `Accept-Encoding` at all stops Net::HTTP decoding the gzip it does
302
+ understand. Inherited from importmap-rails, which downloads the same way.
303
+
304
+ - **`Importmap::Packager::ServiceError` is a class again.** It was assigned
305
+ `Error.new(Error)` — an *instance* — so `rescue Packager::ServiceError`
306
+ raised `TypeError: class or module required for rescue clause`, and every
307
+ jspm service error arrived as a plain `Packager::Error`. Inherited from
308
+ importmap-rails, where it is still the case.
309
+ - **A failed download no longer deletes the file an app already has.**
310
+ `pin` and `pristine` removed `vendor/javascript/<package>.js` before
311
+ fetching, so a CDN that answered 500 — or, now, a file that can't be
312
+ vendored — left the app with no file at all. The existing file is replaced
313
+ only once the new one has arrived and been found fit to serve.
314
+ - **`update <package>` moves every pin of that package, not just the bare
315
+ one.** An app with `pin "pdfjs-dist"` and
316
+ `pin "pdfjs-dist/build/pdf.worker.min.mjs"` ran `update pdfjs-dist` and got
317
+ a new main file next to a worker still at the old version — the two are
318
+ built together and don't tolerate that. A package name now means every
319
+ key that carries it, the same set `outdated` reports and a bare `update`
320
+ moves; `update pdfjs-dist/build/pdf.worker.min.mjs` still means that one
321
+ key.
322
+ - **A subpath pin without a CDN in its comment resolves from the CDN its
323
+ package's pin names.** The worker pin above, written before provenance
324
+ existed, was sent to jspm — which can't resolve pdf.js at all — and
325
+ reported "Couldn't find any packages" on every update while the main pin
326
+ moved on from jsdelivr. Siblings pinned together come from the same place;
327
+ the next update records it on the pin. A pin answers for itself first — its
328
+ own comment, then its own `to:` URL — and only then does its package answer
329
+ for it, whether that pin is vendored, recording the CDN in its comment, or
330
+ remote, carrying it in the URL with no comment at all.
331
+
332
+ - **An array `preload:` survives a rewrite however it is quoted, and
333
+ `preload: []` stays `preload: []`.** The option was read back through
334
+ `JSON.parse`, so `pin 'md5', preload: ['admin']` — single quotes being a
335
+ supported pin shape everywhere else in this gem — took down every command
336
+ that reads a pin with `JSON::ParserError: unexpected character`. And
337
+ `preload: []` matched nothing at all, so `pin`, `update` and `pristine`
338
+ dropped it and quietly restored the `preload: true` default on a package the
339
+ app had asked to preload for no entry point. `config/importmap.rb` is Ruby,
340
+ not JSON: the entry points are scanned out of the literal now, and an empty
341
+ array is written back as one.
342
+
343
+ - **`pin --vendor` leaves a pin to a custom URL alone.** `--vendor` is meant to
344
+ override the check that refuses a download, not the URL an app chose to pin,
345
+ but it reached the vendoring path before the branch that skips custom URLs
346
+ could run: `bin/importmap pin md5 --vendor` against
347
+ `pin "md5", to: "https://cdn.example.com/md5.js"` downloaded md5 from
348
+ whichever CDN the spec resolved to and replaced the line with
349
+ `pin "md5" # @2.3.0 (vendored)`, without a word about the URL it had just
350
+ dropped. It now reports the skip the way a plain `pin` does and names the
351
+ flag in it. Moving such a pin on purpose is still `--from`, which re-resolves
352
+ it from the CDN you name.
353
+
3
354
  ## 1.1.0
4
355
 
5
356
  ### Added
data/README.md CHANGED
@@ -25,8 +25,12 @@ An app without an import map yet:
25
25
  | `--from esm.run` | Vendors jsDelivr's one-file bundle, rewrites its imports to bare specifiers, and pins the dependencies it needs. |
26
26
  | `pin --lock`, `lock`, `unlock` | Holds a package at a version. `update`, `pristine` and `pin` leave it there until you unlock it or pass `--force`. |
27
27
  | `update [PACKAGES] --all --force` | Update by name, or everything explicitly; `--force` moves locked packages and re-locks them. |
28
- | Provenance | The pin comment records the CDN, minification and lock `pin "luxon" # @3.7.2 (esm.run, minified, locked)` — so nothing silently drifts back to jspm. |
29
- | Remote pins stay remote | A pin with a CDN URL is re-resolved from that CDN; `preload:` and a boolean `integrity:` survive every rewrite; a custom URL is left alone. |
28
+ | Multi-file packages | A package whose entry imports siblings by relative path is vendored with its whole file graph, mapped by one `pin_all_from` line. One that needs more than files can give it — a worker, `import.meta.url`, a `.wasm` stays on its CDN with the reason on the pin. |
29
+ | Registry-latest, then CDN fallback | A bare name is resolved on the npm registry, then asked of jspm, esm.run and jsDelivr in turn until one of them has it. `--from` disables the fallback. |
30
+ | `doctor` | Checks the import map against the files that are actually there — offline, reporting only, non-zero on an error — so CI catches a 404 that otherwise shows up on one page in the browser. |
31
+ | Provenance | The pin comment records the CDN, minification, lock and why a package was kept remote — `pin "luxon" # @3.7.2 (esm.run, minified, locked)` — so nothing silently drifts back to jspm. |
32
+ | Remote pins stay remote | A pin with a CDN URL is re-resolved from that CDN and carries a subresource-integrity hash of the bytes that were resolved; `preload:` and a boolean `integrity:` survive every rewrite; a custom URL is left alone. |
33
+ | Preload what the page reaches | `config.importmap.preload_strategy = :reachable` preloads what the entry point actually imports rather than every preloaded pin, and `config.importmap.early_hints` sends those links as a 103 Early Hints response. Both opt-in. |
30
34
  | Requests retry | A reset connection, a timeout or a 429/5xx is tried three times with a growing pause before the command gives up. |
31
35
 
32
36
  ```bash
@@ -34,12 +38,16 @@ An app without an import map yet:
34
38
  ./bin/importmap pin @hotwired/stimulus@3.2.2 --lock
35
39
  ./bin/importmap update stimulus-use
36
40
  ./bin/importmap outdated
41
+ ./bin/importmap doctor
37
42
  ```
38
43
 
39
44
  ```ruby
40
45
  # config/importmap.rb
41
46
  pin "luxon" # @3.7.2 (esm.run, minified)
42
47
  pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2 (locked)
48
+ pin "@popperjs/core", to: "@popperjs--core.js" # @2.11.8
49
+ pin_all_from "vendor/javascript/@popperjs--core", under: "@popperjs/core", to: "@popperjs--core" # @2.11.8 (graph of @popperjs/core)
50
+ pin "fflate", to: "https://ga.jspm.io/npm:fflate@0.8.2/esm/browser.js" # @0.8.2 (remote: workers)
43
51
  ```
44
52
 
45
53
  The full guide — every command, option and behaviour, plus importmap-rails' own documentation for the parts that are unchanged — is at [importmap-plus.zoolutions.llc](https://importmap-plus.zoolutions.llc).
@@ -1,6 +1,9 @@
1
1
  module Importmap::ImportmapTagsHelper
2
2
  # Setup all script tags needed to use an importmap-powered entrypoint (which defaults to application.js)
3
3
  def javascript_importmap_tags(entry_point = "application", importmap: Rails.application.importmap)
4
+ preloaded = importmap.preloaded_module_packages(resolver: self, entry_point:, cache_key: entry_point)
5
+ Importmap::EarlyHints.send_modulepreload_links(self, preloaded)
6
+
4
7
  safe_join [
5
8
  javascript_inline_importmap_tag(importmap.to_json(resolver: self)),
6
9
  javascript_importmap_module_preload_tags(importmap, entry_point:),
@@ -0,0 +1,133 @@
1
+ require "importmap/provider_chain"
2
+
3
+ # Resolves a group of specs against a CDN, batch first and then one at a time.
4
+ #
5
+ # The batch is the fast path and stays: one round trip, and jspm resolves the
6
+ # specs' shared dependencies as a single graph, which is what upstream does.
7
+ # But a CDN answers a batch as a whole — one spec its generator can't build is
8
+ # "no" for all of them — so a refused batch of more than one spec says nothing
9
+ # about the specs in it. Each is then asked on its own, along the path it would
10
+ # have taken alone: through the chain when the group named no CDN, of the same
11
+ # provider once when it did. A package that was always going to resolve is
12
+ # pinned from the CDN it was always going to be pinned from, whatever its
13
+ # siblings did.
14
+ #
15
+ # Specs nobody could resolve are collected rather than raised: each pin is
16
+ # independent of the others, so the rest still land and the exit code says the
17
+ # command didn't do all it was asked.
18
+ class Importmap::BatchResolver
19
+ # The specs no CDN answered for, in the order they were asked.
20
+ attr_reader :unresolved
21
+
22
+ # +on_miss+ is the command's own "couldn't find" reporter, so the sentence a
23
+ # single spec gets is written in exactly one place.
24
+ def initialize(packager, on_miss:, chain: Importmap::ProviderChain.new)
25
+ @packager = packager
26
+ @on_miss = on_miss
27
+ @chain = chain
28
+ @unresolved = []
29
+ end
30
+
31
+ # Yields [ package, url ] for everything that resolved, as it resolves, so a
32
+ # spec that fails later can't discard the work of one that succeeded earlier.
33
+ def each_import(specs, env:, from:, fallback:, &block)
34
+ @named = specs.map { |spec| @packager.package_key_for(spec) }
35
+ @seen = {}
36
+
37
+ if specs.one?
38
+ resolve_one(specs.first, env: env, from: from, fallback: fallback, &block)
39
+ elsif (response = resolve_batch(specs, env: env, from: from, fallback: fallback))
40
+ emit(specs, response, &block)
41
+ else
42
+ specs.each { |spec| resolve_one(spec, env: env, from: from, fallback: fallback, &block) }
43
+ end
44
+ end
45
+
46
+ private
47
+ # The batch a group with no CDN of its own sends goes to jspm alone. Handing
48
+ # the whole list to the next CDN is what moved a healthy package's provenance
49
+ # when a sibling failed; the specs travel the rest of the chain separately.
50
+ def resolve_batch(specs, env:, from:, fallback:)
51
+ provider = fallback ? Importmap::ProviderChain::DEFAULT : from
52
+ error = nil
53
+
54
+ response =
55
+ begin
56
+ @packager.import(*specs, env: env, from: provider)
57
+ rescue Importmap::Packager::Error => raised
58
+ error = raised
59
+ nil
60
+ end
61
+
62
+ return response if response
63
+
64
+ report_split(specs, provider, error&.message || @packager.last_import_error, fallback)
65
+ nil
66
+ end
67
+
68
+ def report_split(specs, provider, reason, fallback)
69
+ detail = Importmap::ProviderChain.tidy_reason(reason)
70
+ miss = if fallback
71
+ %(#{provider} couldn't resolve #{quoted(specs)})
72
+ else
73
+ %(Couldn't find any packages in #{specs.inspect} on #{provider})
74
+ end
75
+
76
+ puts miss + (detail ? %( (#{detail})) : "") + "; asking for each on its own"
77
+ end
78
+
79
+ def resolve_one(spec, env:, from:, fallback:, &block)
80
+ source = fallback ? Importmap::ProviderChain.to_sentence : from
81
+
82
+ if (response = request_one(spec, env: env, from: from, fallback: fallback))
83
+ emit([ spec ], response, &block)
84
+ else
85
+ # A chain that came up empty has already said why each CDN couldn't.
86
+ @on_miss.call([ spec ], source, reason: fallback ? nil : @packager.last_import_error)
87
+ @unresolved << spec
88
+ end
89
+ rescue Importmap::Packager::Error => error
90
+ puts %(Couldn't resolve "#{spec}" from #{source}: #{error.message})
91
+ @unresolved << spec
92
+ end
93
+
94
+ def request_one(spec, env:, from:, fallback:)
95
+ if fallback
96
+ @chain.resolve(@packager, [ spec ], env: env) { |_provider, response| response }
97
+ else
98
+ @packager.import(spec, env: env, from: from)
99
+ end
100
+ end
101
+
102
+ # Split responses overlap: each carries the dependencies of its own spec.
103
+ # A spec the user named answers for itself, so a sibling's response naming
104
+ # it is a dependency edge and not the answer asked for. Anything else is
105
+ # taken from the first response that carried it, because rewriting it to a
106
+ # second URL would move a package the user never mentioned.
107
+ def emit(specs, response, &block)
108
+ asked_for = specs.map { |spec| @packager.package_key_for(spec) }
109
+
110
+ response[:imports].each do |package, url|
111
+ next if @named.include?(package) && !asked_for.include?(package)
112
+
113
+ if (kept = @seen[package])
114
+ report_kept(package, kept, url, specs.first) unless kept == url
115
+ else
116
+ @seen[package] = url
117
+ block.call(package, url)
118
+ end
119
+ end
120
+ end
121
+
122
+ def report_kept(package, kept, url, spec)
123
+ puts %(Keeping "#{package}" at #{version(kept)} ("#{spec}" resolved it to #{version(url)}))
124
+ end
125
+
126
+ def version(url)
127
+ @packager.extract_package_version_from(url) || url
128
+ end
129
+
130
+ def quoted(specs)
131
+ specs.map { |spec| %("#{spec}") }.join(", ")
132
+ end
133
+ end