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.
@@ -0,0 +1,53 @@
1
+ require "importmap/module_inspector"
2
+
3
+ # Every module specifier a JavaScript file names, in source order, with whether
4
+ # the browser resolves it while linking the module or later at runtime. What an
5
+ # import map has to define, read back out of the files the map serves.
6
+ #
7
+ # Like Importmap::ModuleInspector and Importmap::PackageGraph::IMPORT_REGEXP
8
+ # this reads the source with regexes rather than parsing JavaScript, so an
9
+ # import statement spelled out inside a string literal is reported too, and a
10
+ # form the regexp can't read — a magic comment between the keyword and the
11
+ # specifier, a specifier built at runtime — isn't reported at all. Both
12
+ # mistakes are the cautious direction for the one caller: a specifier reported
13
+ # that the browser never asks for costs a pin, and a specifier missed leaves
14
+ # the app exactly as broken as it was before anyone ran the check.
15
+ #
16
+ # Block comments are discounted first, through ModuleInspector#code, because a
17
+ # published bundle is full of `/** @typedef {import('./slide.js').Slide} */` —
18
+ # type annotations naming files the package never loads.
19
+ class Importmap::ImportScanner
20
+ Import = Struct.new(:specifier, :kind, keyword_init: true) # :nodoc:
21
+
22
+ # `import("x")` of a string literal, then every static spelling: the bare
23
+ # `import "x"` and the `from "x"` that ends `import a from "x"`,
24
+ # `import {a} from "x"`, `import * as a from "x"`, `export {a} from "x"` and
25
+ # `export * from "x"`. The lookbehind keeps `loader.import(` and identifiers
26
+ # ending in `import` or `from` out, and the dynamic branch comes first so an
27
+ # `import(` is never read as the bare form.
28
+ #
29
+ # The closing `[),]` on the dynamic branch is what makes a computed
30
+ # specifier — `import(name)`, `import(`./${lang}.js`)` — match nothing
31
+ # rather than match half of something; it is the same test
32
+ # Importmap::ModuleInspector::COMPUTED_IMPORT_REGEXP makes from the other
33
+ # side, and the two must agree about which files hold one.
34
+ IMPORT_REGEXP = /
35
+ (?<![\w.$])
36
+ (?:
37
+ import\s*\(\s*(["'])([^"'\n]*)\1\s*[),] |
38
+ (?:from|import)\s*(["'])([^"'\n]*)\3
39
+ )
40
+ /x.freeze # :nodoc:
41
+
42
+ attr_reader :source
43
+
44
+ def initialize(source)
45
+ @source = source.to_s
46
+ end
47
+
48
+ def imports
49
+ @imports ||= Importmap::ModuleInspector.new(source).code.scan(IMPORT_REGEXP).map do |_, dynamic, _, static|
50
+ dynamic ? Import.new(specifier: dynamic, kind: :dynamic) : Import.new(specifier: static, kind: :static)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,25 @@
1
+ require "digest"
2
+
3
+ # The subresource-integrity hash of a file, in the form the import map's
4
+ # integrity section and a modulepreload link take: "sha384-" and the digest in
5
+ # base64. Computed from the bytes a CDN hands back, which are the bytes the
6
+ # browser will hash when it loads the module.
7
+ #
8
+ # pack("m0") rather than Base64.strict_encode64: base64 stopped being a default
9
+ # gem in Ruby 3.4, and this gem depends on railties, activesupport and actionpack
10
+ # and nothing else.
11
+ module Importmap::Integrity
12
+ ALGORITHM = "sha384".freeze
13
+
14
+ class << self
15
+ def for(body)
16
+ "#{ALGORITHM}-#{[ Digest::SHA384.digest(body) ].pack("m0")}"
17
+ end
18
+
19
+ # A pin's integrity option is true, false, nil or a hash string; only the
20
+ # last is a value this computed, and only it is quoted and printed.
21
+ def hash?(integrity)
22
+ integrity.is_a?(String)
23
+ end
24
+ end
25
+ end
data/lib/importmap/map.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require "pathname"
2
+ require "importmap/graph"
2
3
 
3
4
  class Importmap::Map
4
5
  attr_reader :packages, :directories
@@ -193,6 +194,14 @@ class Importmap::Map
193
194
  end
194
195
  end
195
196
 
197
+ # Yields every key the map defines and the +MappedFile+ it maps to, with the
198
+ # `pin_all_from` directories expanded into the keys they contribute — what
199
+ # #to_json is about to resolve, before a resolver has touched it. Returns an
200
+ # Enumerator without a block.
201
+ def each_expanded_package(&block)
202
+ expanded_packages_and_directories.each(&block)
203
+ end
204
+
196
205
  private
197
206
  MappedDir = Struct.new(:dir, :path, :under, :preload, :integrity, keyword_init: true)
198
207
  MappedFile = Struct.new(:name, :path, :preload, :integrity, keyword_init: true)
@@ -207,6 +216,7 @@ class Importmap::Map
207
216
 
208
217
  def clear_cache
209
218
  @cache.clear
219
+ @graph = nil
210
220
  end
211
221
 
212
222
  def rescuable_asset_error?(error)
@@ -265,7 +275,34 @@ class Importmap::Map
265
275
  end
266
276
 
267
277
  def expanded_preloading_packages_and_directories(entry_point:)
268
- expanded_packages_and_directories.select { |name, mapping| mapping.preload.in?([true, false]) ? mapping.preload : (Array(mapping.preload) & Array(entry_point)).any? }
278
+ preloading = expanded_packages_and_directories.select { |name, mapping| mapping.preload.in?([true, false]) ? mapping.preload : (Array(mapping.preload) & Array(entry_point)).any? }
279
+ reachable_only(preloading, entry_point: entry_point)
280
+ end
281
+
282
+ # With config.importmap.preload_strategy == :reachable, a pin that says
283
+ # `preload: true` is preloaded only when the entry point's own imports
284
+ # reach it, so a package behind an `import()` stops being fetched on every
285
+ # page without anyone maintaining a `preload: false` for it and for
286
+ # everything it depends on. A pin naming the entry point is the app
287
+ # overruling the graph, and `preload: false` is off either way.
288
+ def reachable_only(packages, entry_point:)
289
+ return packages unless Rails.application.config.importmap.preload_strategy == :reachable
290
+
291
+ reachable = graph.reachable_from(Array(entry_point))
292
+ packages.select { |name, mapping| mapping.preload != true || reachable.include?(name) }
293
+ end
294
+
295
+ # Memoised beside the rendered map rather than inside it: #cache_as shares
296
+ # one namespace with the cache_key the preload helper passes, which is the
297
+ # entry point's own name, so an app with an entry point named "graph" would
298
+ # read this back as its preload set. Dropped by the same clear_cache the
299
+ # sweeper calls when a .js file under a watched directory changes.
300
+ def graph
301
+ @graph ||= Importmap::Graph.new(self, roots: asset_paths)
302
+ end
303
+
304
+ def asset_paths
305
+ (config = Rails.application.config).respond_to?(:assets) ? config.assets.paths : []
269
306
  end
270
307
 
271
308
  def expanded_packages_and_directories
@@ -0,0 +1,200 @@
1
+ require "strscan"
2
+
3
+ # Decides whether a downloaded ESM file can stand alone as the single file an
4
+ # import map entry points at. A vendored package is exactly one file served
5
+ # under a digested asset path, so anything the file expects to find beside
6
+ # itself — a sibling module, a worker script, a wasm binary, its own directory
7
+ # via import.meta.url — resolves to a 404 in the browser.
8
+ #
9
+ # Like Importmap::EsmRun::IMPORT_REGEXP this reads the source with regexes
10
+ # rather than parsing JavaScript, so the same text inside a string still counts.
11
+ # It is deliberately the cautious direction: a false positive keeps a working
12
+ # remote pin, and `pin --vendor` is the escape hatch. Every judgement call here
13
+ # leans that way, because the two mistakes are not equal — a package wrongly
14
+ # kept remote still works, a package wrongly vendored 404s in production.
15
+ class Importmap::ModuleInspector
16
+ # `from "./x"`, `from '../x'`, a bare `import "./x"` and a dynamic
17
+ # `import("./x")`. Anchored on the keyword, and the lookbehind keeps
18
+ # `obj.import(` and identifiers ending in `import` out.
19
+ RELATIVE_IMPORT_REGEXP = /(?<![\w.$])(?:from|import)\s*\(?\s*["']\.{1,2}\//.freeze # :nodoc:
20
+ # import() of anything but a string literal: the specifier is computed at
21
+ # runtime, so what it resolves to can't be known here, let alone vendored.
22
+ # The whitespace lives inside the lookahead on purpose: as `import\s*\(\s*`
23
+ # followed by a negative lookahead, `\s*` backtracks to zero and the lookahead
24
+ # then reads the space rather than the quote, so `import( "crypt" )` reads as
25
+ # computed.
26
+ COMPUTED_IMPORT_REGEXP = /(?<![\w.$])import\s*\((?!\s*["'][^"']*["']\s*[),])/.freeze # :nodoc:
27
+ # A worker is fetched as its own top-level script and never goes through the
28
+ # import map, so its URL has to exist on its own.
29
+ # The qualifier group catches `new window.Worker(…)` and `new self.Worker(…)`;
30
+ # it needs the dot, so `new WorkerPool(…)` is still left alone.
31
+ WORKER_REGEXP = /(?<![\w.$])new\s+(?:[\w$]+\s*\.\s*)*(?:Shared)?Worker\s*\(/.freeze # :nodoc:
32
+ # import.meta.url is the file's own digested asset path, which is not the
33
+ # directory the package's other files were published to.
34
+ IMPORT_META_URL_REGEXP = /(?<![\w.$])import\s*\.\s*meta\s*\.\s*url\b/.freeze # :nodoc:
35
+ # A .wasm binary is fetched at runtime by a path the package computes; it is
36
+ # never part of the JavaScript file that names it. Template literals count —
37
+ # the path is usually built from a base — and so does a cache-busting query
38
+ # or a fragment after the extension.
39
+ WASM_REGEXP = /(["'`])[^"'`\n]*\.wasm(?:[?#][^"'`\n]*)?\1/.freeze # :nodoc:
40
+
41
+ # In precedence order: the first one that matches is the reason reported, and
42
+ # relative imports come first because they are both the commonest cause and
43
+ # the one an app developer can act on.
44
+ PATTERNS = {
45
+ "relative imports" => RELATIVE_IMPORT_REGEXP,
46
+ "dynamic imports" => COMPUTED_IMPORT_REGEXP,
47
+ "workers" => WORKER_REGEXP,
48
+ "import.meta.url" => IMPORT_META_URL_REGEXP,
49
+ "wasm" => WASM_REGEXP
50
+ }.freeze # :nodoc:
51
+
52
+ # A string literal, consumed whole so nothing inside it is ever read as
53
+ # code. Unterminated, it simply doesn't match and the quote is stepped over.
54
+ STRING_REGEXP = /"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`/m.freeze # :nodoc:
55
+ # A regex literal, consumed whole for the same reason: `/[/*]/` otherwise
56
+ # hands the block-comment matcher an opener and loses the file to the next
57
+ # `*/`. The body allows an escape or a character class, since both can hold
58
+ # the delimiter.
59
+ REGEXP_LITERAL_REGEXP = %r{/(?![*/])(?:[^/\\\n\[]|\\.|\[(?:[^\]\\\n]|\\.)*\])+/[dgimsuvy]*}.freeze # :nodoc:
60
+ # Whether a `/` opens a regex literal or divides depends on the token before
61
+ # it. Getting it wrong can only keep text that should have been dropped —
62
+ # never drop text that should have been kept — so the cautious reading is
63
+ # the safe one here too. Only the tail of what has been kept is looked at:
64
+ # matching the whole buffer at every slash is quadratic, and pdf.js is a
65
+ # megabyte of minified source with a slash in every other line.
66
+ # `)` and `}` are in the set even though they also end an expression that a
67
+ # `/` would divide: reading `(a + b) / 2` as a regex keeps the text either
68
+ # way, while leaving them out lets `if (x) /[/*]/` open a false comment.
69
+ # Verified against 31 published packages — no verdict changes.
70
+ BEFORE_REGEXP_LITERAL_REGEXP =
71
+ /(?:[(,=:\[!&|?{};+\-*%~^<>)\}]|\b(?:return|throw|typeof|case|in|of|do|else|yield|await|delete|void|instanceof|new))\s*\z/.freeze # :nodoc:
72
+ BLOCK_COMMENT_REGEXP = %r{/\*.*?\*/}m.freeze # :nodoc:
73
+
74
+ # An ESM statement, in every spelling a published bundle uses: `import "x"`,
75
+ # `import a from "b"`, `import a, {b} from "c"`, `import{a}from"b"`,
76
+ # `export{a}`, `export * from "b"`, `export default`, and `export` in front
77
+ # of a declaration. The lookbehind keeps `obj.import(` and `reimport` out;
78
+ # the space the identifier form insists on keeps lodash's `importsKeys,` out,
79
+ # which the `exports` of a UMD wrapper needs no help with.
80
+ ESM_STATEMENT_REGEXP = /
81
+ (?<![\w.$])
82
+ (?:
83
+ import\s*["'{*] | import\s+[\w$]+\s*(?:,|\bfrom\b) |
84
+ export\s*(?:[{*]|\b(?:default|var|let|const|function|class|async)\b)
85
+ )
86
+ /x.freeze # :nodoc:
87
+ # What a CommonJS, AMD or UMD bundle says instead: it assigns to an
88
+ # `exports`, it requires or defines, or it sniffs for the loader it is
89
+ # running under. None of these is
90
+ # proof on its own — an ESM file may well mention `require(` — so they only
91
+ # decide a file that declares no exports of its own, where the only mistake
92
+ # they can make is sending a package on to the next CDN.
93
+ #
94
+ # The loader sniff has to be here because the assignment often isn't:
95
+ # lodash reaches its `exports` through `freeModule.exports`, and spells
96
+ # `module.exports` out only in a comment, which is stripped before any of
97
+ # this is read.
98
+ COMMONJS_REGEXP = /
99
+ (?<![\w.$])(?:module\s*\.\s*exports|exports\s*(?:\.\s*[\w$]+|\[[^\]]+\])\s*=|require\s*\(|define\s*\(|typeof\s+(?:exports|module|define)\s*[!=]=) |
100
+ \.\s*exports\s*=
101
+ /x.freeze # :nodoc:
102
+ LINE_COMMENT_REGEXP = %r{//[^\n]*}.freeze # :nodoc:
103
+
104
+ attr_reader :source
105
+
106
+ # The source as it would be written to vendor/javascript: after an esm.run
107
+ # bundle's imports have been rewritten to bare specifiers, before minifying.
108
+ def initialize(source)
109
+ @source = source.to_s
110
+ end
111
+
112
+ # Every pattern the source matches, in precedence order.
113
+ def reasons
114
+ @reasons ||= PATTERNS.filter_map { |reason, regexp| reason if code.match?(regexp) }
115
+ end
116
+
117
+ def reason
118
+ reasons.first
119
+ end
120
+
121
+ def vendorable?
122
+ reasons.empty?
123
+ end
124
+
125
+ # Whether the file is something an import map entry can resolve to: it says
126
+ # what it exports, or at least never says it is CommonJS. A UMD bundle loaded
127
+ # as a module runs and exports nothing, so `import x from "pkg"` fails to
128
+ # link — "The requested module does not provide an export named 'default'" —
129
+ # and takes every module that imported it down too, in the browser only.
130
+ #
131
+ # The two halves read different text, each in the direction that keeps a
132
+ # non-module out. An import statement counts only outside a string literal
133
+ # and outside a line comment, because a bundle that ships a usage example in
134
+ # either is still CommonJS; a `module.exports` counts wherever it appears,
135
+ # because a UMD wrapper hidden in a string is a UMD wrapper.
136
+ def es_module?
137
+ statements.match?(ESM_STATEMENT_REGEXP) || !code.match?(COMMONJS_REGEXP)
138
+ end
139
+
140
+ # The source with its block comments discounted: what every pattern above is
141
+ # matched against, and what Importmap::PackageGraph reads its specifiers out
142
+ # of, so the crawl follows exactly the imports this class counted.
143
+ def code
144
+ @code ||= without_block_comments
145
+ end
146
+
147
+ private
148
+ def statements
149
+ @statements ||= without_block_comments(statements_only: true)
150
+ end
151
+
152
+ # Block comments are discounted before anything is matched. A published
153
+ # bundle is full of `/** @typedef {import('./slide.js').Slide} Slide */` —
154
+ # type annotations naming files the package never loads, which would
155
+ # otherwise keep a self-contained package remote; photoswipe alone carries
156
+ # 76 of them.
157
+ #
158
+ # The scan walks the source instead of running a `/\*.*?\*/` over it,
159
+ # because that regex reads a `"/*"` inside a string as a comment opener and
160
+ # swallows the code up to the next `*/` — and an `import "./sibling.js"`
161
+ # swallowed there is precisely the 404 this class exists to catch. Strings
162
+ # are matched first and kept whole, so a comment opener inside one is never
163
+ # reached.
164
+ #
165
+ # Line comments are left alone. Stripping them would mean reading `//` as
166
+ # an opener inside a regex literal such as `[//]`, which is the same trap
167
+ # in the same dangerous direction, and nothing is known to hide behind one.
168
+ # With +statements_only+ the line comments go too, and every literal is
169
+ # emptied rather than kept — its delimiters stay, so `import "x"` still
170
+ # reads as an import statement while the text inside it stops being read
171
+ # as code at all. That mode feeds only the ES-module check, where the
172
+ # `[//]` trap above can at worst send a package on to the next CDN.
173
+ def without_block_comments(statements_only: false)
174
+ scanner = StringScanner.new(source)
175
+ kept = +""
176
+
177
+ until scanner.eos?
178
+ if scanner.skip(BLOCK_COMMENT_REGEXP) || (statements_only && scanner.skip(LINE_COMMENT_REGEXP))
179
+ next
180
+ elsif (literal = scanner.scan(STRING_REGEXP)) ||
181
+ (regexp_literal_next?(kept, scanner) && (literal = scanner.scan(REGEXP_LITERAL_REGEXP)))
182
+ kept << (statements_only ? literal[0, 1] * 2 : literal)
183
+ else
184
+ kept << scanner.getch
185
+ end
186
+ end
187
+
188
+ kept
189
+ end
190
+
191
+ # Wide enough for the longest keyword above plus the indentation a
192
+ # pretty-printed file can put between it and the slash; a keyword the
193
+ # window cuts in half simply reads as division, which keeps less.
194
+ REGEXP_LOOKBEHIND_LIMIT = 32 # :nodoc:
195
+
196
+ def regexp_literal_next?(kept, scanner)
197
+ scanner.match?(%r{/}) &&
198
+ (kept[-REGEXP_LOOKBEHIND_LIMIT..] || kept).match?(BEFORE_REGEXP_LITERAL_REGEXP)
199
+ end
200
+ end
data/lib/importmap/npm.rb CHANGED
@@ -43,6 +43,15 @@ class Importmap::Npm
43
43
  end.sort_by(&:name)
44
44
  end
45
45
 
46
+ # The version the registry calls latest, or nil when it couldn't be asked.
47
+ # The registry is authoritative about what a package's latest version is,
48
+ # where a CDN answers with the latest it happens to have built.
49
+ def latest_version(package)
50
+ response = get_package(package)
51
+
52
+ find_latest_version(response)&.to_s unless response.nil? || response["error"]
53
+ end
54
+
46
55
  def vulnerable_packages
47
56
  get_audit.flat_map do |package, vulnerabilities|
48
57
  vulnerabilities.map do |vulnerability|
@@ -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