importmap-plus 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,299 @@
1
+ require "uri"
2
+ require "importmap/module_inspector"
3
+ require "importmap/integrity"
4
+ require "importmap/vendored_graph"
5
+
6
+ # The sibling files a chunked ESM download needs, fetched from the same CDN
7
+ # directory and rewritten so the import map can serve them.
8
+ #
9
+ # A published package that splits itself across files — +import"./_/f08a6ffe.js"+
10
+ # — cannot be vendored as the one file an import map entry points at: Propshaft
11
+ # and Sprockets digest file names and neither rewrites +import+ statements, so
12
+ # the browser asks for a path that no longer exists. The files are a closed set,
13
+ # though: everything an entry reaches through relative imports lives under the
14
+ # package's own version directory on the CDN. This crawls that set, turns every
15
+ # relative specifier into the bare key +<package>/<path without extension>+, and
16
+ # hands back the files for Importmap::Packager to write into a directory that
17
+ # one +pin_all_from+ line maps.
18
+ #
19
+ # Nothing is written here and nothing is written by the caller until the crawl
20
+ # has finished: a file it can't own — a path that escapes the package root, a
21
+ # sibling that isn't JavaScript, a chunk that spawns a worker — raises
22
+ # Unownable, and the whole package stays pinned to its CDN, which works.
23
+ class Importmap::PackageGraph
24
+ # The one Importmap::ModuleInspector reason a graph answers for. Every other
25
+ # reason is about a file the browser fetches by a path nobody can rewrite.
26
+ REASON = "relative imports".freeze # :nodoc:
27
+
28
+ # The CDNs whose URLs say which package and version a file belongs to, and so
29
+ # where the package's directory ends. esm.sh and skypack serve a package from
30
+ # paths that don't spell that out, so a download from them is left remote.
31
+ ROOT_REGEXPS = [
32
+ %r{\Ahttps://ga\.jspm\.io/npm:((?:@[^/@]+/)?[^/@]+)@([^/]+)/},
33
+ %r{\Ahttps://cdn\.jsdelivr\.net/npm/((?:@[^/@]+/)?[^/@]+)@([^/]+)/},
34
+ %r{\Ahttps://unpkg\.com/((?:@[^/@]+/)?[^/@]+)@([^/]+)/}
35
+ ].map(&:freeze).freeze # :nodoc:
36
+
37
+ # Importmap::ModuleInspector::RELATIVE_IMPORT_REGEXP with the specifier
38
+ # captured, so the same forms it counts are the ones rewritten here. Like
39
+ # that one and Importmap::EsmRun::IMPORT_REGEXP it doesn't parse JavaScript,
40
+ # so a data string that spells out an import statement is rewritten inside
41
+ # the string too, and a form it can't read — a magic comment between the
42
+ # keyword and the specifier, an unterminated literal — isn't rewritten at
43
+ # all. #verify_rewritten is what makes the second kind safe: a file the crawl
44
+ # reached and couldn't rewrite keeps the whole package remote.
45
+ IMPORT_REGEXP =
46
+ /((?<![\w.$])(?:from|import)\s*\(?\s*)(["'])(\.{1,2}\/[^"'\n]*)\2/.freeze # :nodoc:
47
+
48
+ # A path under the package root that can become a file in vendor/javascript
49
+ # and a key in the import map: no query, no fragment, nothing but a plain
50
+ # relative path, and an extension Importmap::Map's directory glob picks up.
51
+ #
52
+ # Every segment is a plain name that doesn't begin with a dot, which rejects
53
+ # four paths a CDN can hand back and this class must not act on: "..", which
54
+ # climbs out of the package; an empty segment, as "a//b" has, whose key would
55
+ # be one Importmap::Map never emits for the file it writes; a leading "/",
56
+ # which Pathname#join turns into an absolute path outside vendor/javascript
57
+ # altogether; and a dot-directory, which Map's `**/*.js{,m}` glob doesn't
58
+ # descend into, so its files would be written and never mapped.
59
+ PATH_REGEXP =
60
+ %r{\A[A-Za-z0-9_@+\-][A-Za-z0-9._@+\-]*(?:/[A-Za-z0-9_@+\-][A-Za-z0-9._@+\-]*)*\.m?js\z}.freeze # :nodoc:
61
+
62
+ # Importmap::Map's directory expansion drops a trailing "index" from a key,
63
+ # so lib/index.js is reached as "<package>/lib" and index.js as "<package>".
64
+ INDEX_REGEXP = %r{(?:/|\A)index\z}.freeze # :nodoc:
65
+
66
+ # A file the crawl reached and can't take responsibility for. Translated into
67
+ # Importmap::Packager::Unvendorable, which carries the hash of the bytes the
68
+ # CDN served so the pin kept remote doesn't fetch them again.
69
+ class Unownable < StandardError
70
+ attr_reader :reasons
71
+
72
+ def initialize(reasons)
73
+ @reasons = Array(reasons)
74
+ super("needs more than its file graph (#{@reasons.join(", ")})")
75
+ end
76
+ end
77
+
78
+ # The graph +source+ needs beside it, or nil when this download is one file:
79
+ # it imports no siblings, it isn't an ES module, or it comes from a CDN whose
80
+ # package directory can't be addressed. +package+ is the import-map key the
81
+ # entry itself is pinned under, +known+ maps the CDN URL of every file
82
+ # another pin already vendored to that pin's key, and +forbidden+ lists the
83
+ # keys the directory must not define. The block fetches a URL and answers nil
84
+ # when the CDN hasn't got it.
85
+ def self.build(url, source, package:, known: {}, forbidden: [], &fetcher)
86
+ root, name = root_and_package(url).values_at(0, 1)
87
+ return unless root
88
+
89
+ inspection = Importmap::ModuleInspector.new(source)
90
+ return unless inspection.reasons.include?(REASON) && inspection.es_module?
91
+
92
+ # The graph answers for the relative imports; anything else the entry does
93
+ # is why the package still can't be vendored, so only that is reported.
94
+ raise Unownable.new(inspection.reasons - [ REASON ]) unless inspection.reasons == [ REASON ]
95
+
96
+ new(root, name, url, source, package: package, known: known, forbidden: forbidden, &fetcher).crawl
97
+ end
98
+
99
+ # The graph a Packager download needs beside it, or nil when it is one file.
100
+ # A file the crawl can't own keeps the whole package remote, carrying the
101
+ # hash of the bytes the CDN served — as served, so the pin doesn't fetch them
102
+ # a second time. Every URL another pin already vendored is passed in as one
103
+ # the graph resolves to that pin's key rather than copies, and every key
104
+ # another pin owns as one it may not define.
105
+ def self.for_download(packager, package, url, source, body)
106
+ pins = packager.pinned_packages
107
+ known = Importmap::VendoredGraph.entry_urls(pins.to_h { |key| [ key, packager.vendored_package_path(key) ] })
108
+
109
+ build(url, source, package: package, known: known, forbidden: pins - [ package ]) do |file_url|
110
+ # Tagged the way the entry is: Net::HTTP hands back ASCII-8BIT, which
111
+ # neither the rewrite's regexes nor the write can read as text.
112
+ #
113
+ # Only a 404 answers with nil, and only a 404 means the crawl can't own
114
+ # the package. A 503 that outlives the retries is a fact about the CDN,
115
+ # not about the package: swallowing it here would keep a working vendored
116
+ # package remote and delete the files that made it work. It is raised,
117
+ # and the CLI reports it and leaves the pin alone.
118
+ packager.fetch_remote(file_url, allow_missing: true)&.force_encoding("UTF-8")
119
+ end
120
+ rescue Unownable => refusal
121
+ raise Importmap::Packager::Unvendorable.new(refusal.reasons, integrity: Importmap::Integrity.for(body))
122
+ end
123
+
124
+ # The package a CDN URL names, or nil for a CDN whose paths don't say which
125
+ # package and version a file belongs to.
126
+ def self.package_for(url)
127
+ package_and_version_for(url).first
128
+ end
129
+
130
+ # That package and the version the URL pins it at — the version the graph is
131
+ # of, and the fallback for a line's comment when the version isn't the
132
+ # semver Packager#extract_package_version_from looks for.
133
+ def self.package_and_version_for(url)
134
+ root_and_package(url).values_at(1, 2)
135
+ end
136
+
137
+ # The package's own version directory on the CDN, the package it holds and
138
+ # that package's version, as a MatchData ([] when the CDN is one whose paths
139
+ # don't say).
140
+ def self.root_and_package(url)
141
+ ROOT_REGEXPS.filter_map { |regexp| url.to_s.match(regexp) }.first || []
142
+ end
143
+
144
+ # The package the CDN URL names, which is the prefix every key the directory
145
+ # defines is written under. Not always the package the pin's key names: jspm
146
+ # resolves Node's "buffer" to a file in @jspm/core.
147
+ attr_reader :under
148
+
149
+ # { "lib/enums.js" => source }, relative to the directory the caller writes,
150
+ # with every specifier rewritten. An .mjs sibling is named .js here, because
151
+ # Importmap::Map's directory glob doesn't look for .mjs.
152
+ attr_reader :files
153
+
154
+ attr_reader :entry_source
155
+
156
+ def initialize(root, under, url, source, package:, known: {}, forbidden: [], &fetcher)
157
+ @root, @under, @entry_url, @entry_source = root, under, url, source
158
+ @package, @known, @forbidden, @fetcher = package, known, forbidden, fetcher
159
+ @sources, @files, @keys = {}, {}, {}
160
+ end
161
+
162
+ def crawl
163
+ discover
164
+ assign_keys
165
+ rewrite
166
+ verify_rewritten
167
+
168
+ self
169
+ end
170
+
171
+ def size
172
+ files.size
173
+ end
174
+
175
+ private
176
+ # Breadth-first from the entry, following the relative imports each file
177
+ # makes in code — a specifier that only appears in a comment is not
178
+ # fetched, because a JSDoc @typedef names files a package never ships and
179
+ # asking the CDN for one 404s a package that vendors perfectly well.
180
+ def discover
181
+ @sources[@entry_url] = @entry_source
182
+ queue = [ @entry_url ]
183
+
184
+ until queue.empty?
185
+ url = queue.shift
186
+
187
+ imports_in(@sources[url], url).each do |target|
188
+ next if @sources.key?(target) || @known.key?(target)
189
+
190
+ @sources[target] = fetch(target)
191
+ queue << target
192
+ end
193
+ end
194
+ end
195
+
196
+ def imports_in(source, url)
197
+ Importmap::ModuleInspector.new(source).code.scan(IMPORT_REGEXP).filter_map do |_keyword, _quote, specifier|
198
+ target = resolve(url, specifier)
199
+
200
+ raise Unownable.new(REASON) unless target&.start_with?(@root) && vendorable_path?(path_of(target))
201
+
202
+ target
203
+ end.uniq
204
+ end
205
+
206
+ def fetch(url)
207
+ source = @fetcher.call(url)
208
+
209
+ # The CDN hasn't got the file the specifier names, so the browser
210
+ # wouldn't either: the crawl can't own this package.
211
+ raise Unownable.new(REASON) unless source
212
+
213
+ # A sibling may import siblings of its own — that is what a chunk does —
214
+ # but anything else it needs is as unreachable here as it is in the entry.
215
+ inspection = Importmap::ModuleInspector.new(source)
216
+ blockers = inspection.reasons - [ REASON ]
217
+
218
+ raise Unownable.new(blockers) if blockers.any?
219
+ raise Unownable.new("not an ES module") unless inspection.es_module?
220
+
221
+ source
222
+ end
223
+
224
+ # The entry keeps its own flat file and its own pin, so it is a key the
225
+ # graph resolves to rather than a file it writes; so is every file another
226
+ # pin already vendored, which must not be copied a second time — two copies
227
+ # of one module in an import map are two modules, evaluated twice.
228
+ def assign_keys
229
+ @keys = @known.merge(@entry_url => @package)
230
+
231
+ (@sources.keys - [ @entry_url ]).each do |url|
232
+ path = path_of(url).sub(/\.mjs\z/, ".js")
233
+ key = key_for(path)
234
+
235
+ # Two files under one key would give the import map one of them and
236
+ # lose the other; a key another pin owns would have the directory
237
+ # quietly take that pin's place, since pin_all_from wins over pin.
238
+ raise Unownable.new(REASON) if @keys.value?(key) || @forbidden.include?(key)
239
+
240
+ @files[path] = url
241
+ @keys[url] = key
242
+ end
243
+
244
+ # Two paths that differ only in case are one file on a case-insensitive
245
+ # filesystem, where the second write wins and one key serves the other
246
+ # module. Refusing is the same answer everywhere, rather than a package
247
+ # that vendors on Linux and misbehaves on a Mac.
248
+ raise Unownable.new(REASON) if @files.keys.map(&:downcase).uniq.size != @files.size
249
+ end
250
+
251
+ # Every relative import Importmap::ModuleInspector counted has to have come
252
+ # back as a bare key, or the file about to be written asks the browser for
253
+ # a path beside a digested asset. The rewrite reads the raw source while
254
+ # the crawl reads the source with block comments discounted, so a form the
255
+ # two disagree about — a magic comment between the keyword and the
256
+ # specifier — is caught here rather than shipped.
257
+ def verify_rewritten
258
+ sources = files.values + [ entry_source ]
259
+
260
+ raise Unownable.new(REASON) if sources.any? { |source| Importmap::ModuleInspector.new(source).reasons.include?(REASON) }
261
+ end
262
+
263
+ def key_for(path)
264
+ suffix = path.chomp(File.extname(path)).sub(INDEX_REGEXP, "")
265
+
266
+ suffix.empty? ? @under : "#{@under}/#{suffix}"
267
+ end
268
+
269
+ def rewrite
270
+ @entry_source = rewrite_specifiers(@sources[@entry_url], @entry_url)
271
+ @files.transform_values! { |url| rewrite_specifiers(@sources[url], url) }
272
+ end
273
+
274
+ # A specifier whose file the crawl owns becomes that file's key; anything
275
+ # else is left exactly as it was, which is how a relative path inside a
276
+ # comment survives untouched.
277
+ def rewrite_specifiers(source, url)
278
+ source.gsub(IMPORT_REGEXP) do
279
+ keyword, quote, specifier = $1, $2, $3
280
+ key = @keys[resolve(url, specifier)]
281
+
282
+ key ? "#{keyword}#{quote}#{key}#{quote}" : $&
283
+ end
284
+ end
285
+
286
+ def resolve(url, specifier)
287
+ URI.join(url, specifier).to_s
288
+ rescue URI::Error
289
+ nil
290
+ end
291
+
292
+ def path_of(url)
293
+ url.delete_prefix(@root)
294
+ end
295
+
296
+ def vendorable_path?(path)
297
+ path.match?(PATH_REGEXP) && !path.split("/").include?("..")
298
+ end
299
+ end