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,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
|
@@ -19,8 +19,13 @@ class Importmap::Npm
|
|
|
19
19
|
@vendor_path = Pathname.new(vendor_path)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
# With +only:+, just those packages are looked up; names may carry a
|
|
23
|
+
# subpath (apexcharts/core), which the registry doesn't know about.
|
|
24
|
+
def outdated_packages(only: nil)
|
|
25
|
+
wanted = only&.map { |name| extract_base_package_name(name) }
|
|
26
|
+
candidates = wanted ? packages_with_versions.select { |package, _| wanted.include?(package) } : packages_with_versions
|
|
27
|
+
|
|
28
|
+
candidates.each_with_object([]) do |(package, current_version), outdated_packages|
|
|
24
29
|
outdated_package = OutdatedPackage.new(name: package, current_version: current_version)
|
|
25
30
|
|
|
26
31
|
if !(response = get_package(package))
|
|
@@ -38,6 +43,15 @@ class Importmap::Npm
|
|
|
38
43
|
end.sort_by(&:name)
|
|
39
44
|
end
|
|
40
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
|
+
|
|
41
55
|
def vulnerable_packages
|
|
42
56
|
get_audit.flat_map do |package, vulnerabilities|
|
|
43
57
|
vulnerabilities.map do |vulnerability|
|
|
@@ -51,21 +65,25 @@ class Importmap::Npm
|
|
|
51
65
|
end.sort_by { |p| [p.name, p.severity] }
|
|
52
66
|
end
|
|
53
67
|
|
|
68
|
+
# Memoized: a command that asks twice would otherwise report the
|
|
69
|
+
# unversioned packages twice.
|
|
54
70
|
def packages_with_versions
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
importmap.scan(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
@packages_with_versions ||= begin
|
|
72
|
+
# We cannot use the name after "pin" because some dependencies are loaded from inside packages
|
|
73
|
+
# Eg. pin "buffer", to: "https://ga.jspm.io/npm:@jspm/core@2.0.0-beta.19/nodelibs/browser/buffer.js"
|
|
74
|
+
with_versions = importmap.scan(/^pin .*(?<=npm:|npm\/|skypack\.dev\/|unpkg\.com\/|esm\.sh\/|esm\.sh\/\*)([^@\/]+)@(\d+\.\d+\.\d+(?:[^\/\s"']*))/) |
|
|
75
|
+
importmap.scan(/#{PIN_REGEX} #.*@(\d+\.\d+\.\d+(?:[^\s]*)).*$/)
|
|
76
|
+
|
|
77
|
+
with_versions.map! do |package, version|
|
|
78
|
+
[extract_base_package_name(package), version]
|
|
79
|
+
end.uniq!
|
|
80
|
+
|
|
81
|
+
vendored_packages_without_version(with_versions).each do |package, path|
|
|
82
|
+
$stdout.puts "Ignoring #{package} (#{path}) since no version is specified in the importmap"
|
|
83
|
+
end
|
|
63
84
|
|
|
64
|
-
|
|
65
|
-
$stdout.puts "Ignoring #{package} (#{path}) since no version is specified in the importmap"
|
|
85
|
+
with_versions
|
|
66
86
|
end
|
|
67
|
-
|
|
68
|
-
with_versions
|
|
69
87
|
end
|
|
70
88
|
|
|
71
89
|
private
|
|
@@ -84,6 +102,11 @@ class Importmap::Npm
|
|
|
84
102
|
JSON.parse(response)
|
|
85
103
|
rescue JSON::ParserError
|
|
86
104
|
nil
|
|
105
|
+
rescue HTTPError => error
|
|
106
|
+
# One package the registry won't answer for shouldn't end the run: the
|
|
107
|
+
# caller records it as unchecked, so the rest are still reported on.
|
|
108
|
+
# with_retries has already spent its attempts by the time we get here.
|
|
109
|
+
{ "error" => error.message }
|
|
87
110
|
end
|
|
88
111
|
|
|
89
112
|
def get_json(uri)
|