importmap-plus 1.1.1 → 2.0.1

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