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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +420 -0
- data/README.md +44 -430
- data/app/helpers/importmap/importmap_tags_helper.rb +3 -0
- data/lib/importmap/batch_resolver.rb +133 -0
- data/lib/importmap/commands.rb +546 -43
- data/lib/importmap/doctor.rb +331 -0
- data/lib/importmap/early_hints.rb +50 -0
- data/lib/importmap/engine.rb +3 -0
- data/lib/importmap/esm_run.rb +118 -0
- data/lib/importmap/graph.rb +126 -0
- data/lib/importmap/import_scanner.rb +53 -0
- data/lib/importmap/integrity.rb +25 -0
- data/lib/importmap/map.rb +38 -1
- data/lib/importmap/module_inspector.rb +200 -0
- data/lib/importmap/npm.rb +37 -14
- data/lib/importmap/package_graph.rb +299 -0
- data/lib/importmap/packager.rb +432 -117
- data/lib/importmap/provider_chain.rb +89 -0
- data/lib/importmap/vendored_graph.rb +214 -0
- data/lib/importmap/version.rb +1 -1
- metadata +18 -6
|
@@ -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
|