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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +351 -0
- data/README.md +10 -2
- data/app/helpers/importmap/importmap_tags_helper.rb +3 -0
- data/lib/importmap/batch_resolver.rb +133 -0
- data/lib/importmap/commands.rb +333 -32
- 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 +9 -0
- data/lib/importmap/package_graph.rb +299 -0
- data/lib/importmap/packager.rb +302 -110
- data/lib/importmap/provider_chain.rb +89 -0
- data/lib/importmap/vendored_graph.rb +214 -0
- data/lib/importmap/version.rb +1 -1
- metadata +12 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
require "importmap/packager"
|
|
2
|
+
|
|
3
|
+
# The CDNs a package that names none of its own is asked of, in order.
|
|
4
|
+
#
|
|
5
|
+
# jspm is first because its generator builds an ES module for packages that
|
|
6
|
+
# ship none, which is the thing an import map needs and the reason
|
|
7
|
+
# importmap-rails asks it and nothing else. The same generator also gives up
|
|
8
|
+
# outright on packages it can't build — a subpath one dependency doesn't
|
|
9
|
+
# export, a module it can't find — and that is a fact about the generator, not
|
|
10
|
+
# about the package: jsDelivr publishes a bundle of the very same version.
|
|
11
|
+
# So a miss moves to the next CDN rather than ending the run.
|
|
12
|
+
#
|
|
13
|
+
# Only a package with no CDN of its own travels the chain. An explicit --from
|
|
14
|
+
# and the provider a pin's comment records are both choices somebody made, and
|
|
15
|
+
# a choice is asked once and reported on.
|
|
16
|
+
class Importmap::ProviderChain
|
|
17
|
+
PROVIDERS = [ "jspm", "esm.run", "jsdelivr" ].freeze # :nodoc:
|
|
18
|
+
DEFAULT = PROVIDERS.first # :nodoc:
|
|
19
|
+
|
|
20
|
+
# jspm answers to two names: --from takes "jspm", a pin comment records the
|
|
21
|
+
# provider as Packager::DEFAULT_PROVIDER, "jspm.io".
|
|
22
|
+
class << self
|
|
23
|
+
def default?(provider)
|
|
24
|
+
provider && normalize(provider) == DEFAULT
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def normalize(provider)
|
|
28
|
+
provider.to_s == Importmap::Packager::DEFAULT_PROVIDER ? DEFAULT : provider.to_s
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Every CDN in the chain, for the sentence that reports a package none of
|
|
32
|
+
# them had: "on jspm, esm.run or jsdelivr".
|
|
33
|
+
def to_sentence
|
|
34
|
+
PROVIDERS.to_sentence(two_words_connector: " or ", last_word_connector: " or ")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# jspm prefixes its generator's messages with "Error: ", which reads as
|
|
38
|
+
# noise once the sentence around it already says something went wrong.
|
|
39
|
+
def tidy_reason(reason)
|
|
40
|
+
reason.to_s.delete_prefix("Error: ").presence
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(providers = PROVIDERS)
|
|
45
|
+
@providers = providers
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Asks each CDN in turn for +specs+ and yields [ provider, response ] for the
|
|
49
|
+
# first that answers, returning what the block returns. Returns nil when none
|
|
50
|
+
# of them did, having said why each one couldn't.
|
|
51
|
+
#
|
|
52
|
+
# A CDN that answers "no" is a fact about the package, so the next one is
|
|
53
|
+
# asked; one that couldn't be reached at all is a fact about the run, and
|
|
54
|
+
# when that is how the chain ends the error is raised rather than reported as
|
|
55
|
+
# a package nobody has.
|
|
56
|
+
def resolve(packager, specs, env:)
|
|
57
|
+
error = nil
|
|
58
|
+
|
|
59
|
+
@providers.each_with_index do |provider, index|
|
|
60
|
+
response =
|
|
61
|
+
begin
|
|
62
|
+
error = nil
|
|
63
|
+
packager.import(*specs, env: env, from: provider)
|
|
64
|
+
rescue Importmap::Packager::Error => raised
|
|
65
|
+
error = raised
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
return yield(provider, response) if response
|
|
70
|
+
|
|
71
|
+
report(provider, specs, error&.message || packager.last_import_error, @providers[index + 1])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
raise error if error
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
def report(provider, specs, reason, next_provider)
|
|
79
|
+
detail = self.class.tidy_reason(reason)
|
|
80
|
+
|
|
81
|
+
puts %(#{provider} couldn't resolve #{quoted(specs)}) +
|
|
82
|
+
(detail ? %( (#{detail})) : "") +
|
|
83
|
+
(next_provider ? %(; trying #{next_provider}) : "")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def quoted(specs)
|
|
87
|
+
Array(specs).map { |spec| %("#{spec}") }.join(", ")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "pathname"
|
|
3
|
+
|
|
4
|
+
# The directory a chunked package's sibling files are vendored into, and the
|
|
5
|
+
# one +pin_all_from+ line that maps them.
|
|
6
|
+
#
|
|
7
|
+
# Importmap::PackageGraph crawls and rewrites the files; this puts them on disk
|
|
8
|
+
# and in +config/importmap.rb+. The directory is named for the pin that owns it
|
|
9
|
+
# — the entry's own filename without its extension — so two pins of one package
|
|
10
|
+
# each rebuild their own directory without deleting the other's, while the keys
|
|
11
|
+
# both write go under the package the CDN URL names, so a module they share
|
|
12
|
+
# resolves to one key and the browser evaluates it once.
|
|
13
|
+
class Importmap::VendoredGraph
|
|
14
|
+
# What the line's comment says the directory is:
|
|
15
|
+
#
|
|
16
|
+
# pin_all_from "vendor/javascript/@popperjs--core", under: "@popperjs/core", to: "@popperjs--core" # @2.11.8 (graph of @popperjs/core)
|
|
17
|
+
#
|
|
18
|
+
GRAPH_DETAIL = "graph of".freeze # :nodoc:
|
|
19
|
+
|
|
20
|
+
# Every graph line an import map carries, as [ directory, under, version ].
|
|
21
|
+
MAPPING_REGEXP =
|
|
22
|
+
/^[ \t]*pin_all_from\s+["']([^"']+)["'].*?under:\s*["']([^"']+)["'].*#\s*@([^\s(]+)\s+\(#{GRAPH_DETAIL} /.freeze # :nodoc:
|
|
23
|
+
|
|
24
|
+
# Matched on the whole directory path, so a directory whose name begins with
|
|
25
|
+
# another's doesn't answer for it; anchored on the statement, so a line that
|
|
26
|
+
# only mentions one — a commented-out line, a second statement after a pin —
|
|
27
|
+
# is left alone; and required to carry the comment this class writes, so a
|
|
28
|
+
# pin_all_from an app wrote itself is never rewritten, and the directory it
|
|
29
|
+
# maps is never taken for one of ours to delete.
|
|
30
|
+
def self.line_regexp_for(directory)
|
|
31
|
+
/^[ \t]*pin_all_from\s+["']#{Regexp.escape(directory.to_s)}["'].*#\s*@[^\s(]+\s+\(#{GRAPH_DETAIL} [^)]*\).*$/
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.mappings_in(importmap)
|
|
35
|
+
importmap.to_s.lines.filter_map { |line| line.match(MAPPING_REGEXP)&.captures }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The package whose graph directory already maps +key+, or nil.
|
|
39
|
+
# Importmap::Map expands directories over packages, so a key a directory
|
|
40
|
+
# defines resolves to that file however the pin beside it is written.
|
|
41
|
+
def self.mapping_for(importmap, key)
|
|
42
|
+
mappings_in(importmap).find do |directory, under, _version|
|
|
43
|
+
(key == under || key.start_with?("#{under}/")) &&
|
|
44
|
+
file_in(directory, key.delete_prefix(under).delete_prefix("/"))
|
|
45
|
+
end&.at(1)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Another directory mapping the same package at another version, as
|
|
49
|
+
# [ directory, package, version ]. Both write their files under the same
|
|
50
|
+
# prefix, so for every file they share the app gets one of the two versions,
|
|
51
|
+
# and which one depends on the order of the lines.
|
|
52
|
+
def self.conflict_in(importmap, under:, version:, except:)
|
|
53
|
+
mappings_in(importmap).find do |directory, other, other_version|
|
|
54
|
+
other == under && other_version != version.to_s.delete_prefix("@") && directory != except.to_s
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The file in a directory that answers for +path+, the way Importmap::Map's
|
|
59
|
+
# expansion reaches it, or nil.
|
|
60
|
+
def self.file_in(directory, path)
|
|
61
|
+
candidates = path.empty? ? [ "index.js" ] : [ "#{path}.js", "#{path}/index.js" ]
|
|
62
|
+
|
|
63
|
+
candidates.map { |candidate| Pathname.new(directory).join(candidate) }.find(&:file?)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The header Importmap::Packager writes at the top of every vendored file,
|
|
67
|
+
# read back to tell which CDN file a vendored file already is.
|
|
68
|
+
DOWNLOADED_FROM_REGEXP = %r{\A// \S+ downloaded from (\S+)}.freeze # :nodoc:
|
|
69
|
+
|
|
70
|
+
# The CDN URL every vendored file was downloaded from, by the pin key that
|
|
71
|
+
# names it. A crawl that reaches one of these URLs has reached another pin's
|
|
72
|
+
# file: it rewrites the specifier to that pin's key rather than copying the
|
|
73
|
+
# file, because two copies of one module in an import map are two modules.
|
|
74
|
+
#
|
|
75
|
+
# Read as bytes: a vendored file the gem didn't write, or wrote before it
|
|
76
|
+
# learned to ask CDNs for a body it can read, can hold a sequence no UTF-8
|
|
77
|
+
# regexp will match without raising, and one such file must not stop every
|
|
78
|
+
# other package from being pinned.
|
|
79
|
+
def self.entry_urls(paths_by_key)
|
|
80
|
+
paths_by_key.each_with_object({}) do |(key, path), urls|
|
|
81
|
+
next unless path.file?
|
|
82
|
+
|
|
83
|
+
url = File.open(path, "rb") { |file| file.gets.to_s }[DOWNLOADED_FROM_REGEXP, 1]
|
|
84
|
+
urls[url] = key if url
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A directory in the way that this gem didn't write. Nothing is renamed over
|
|
89
|
+
# an app's own files; the download stops instead and says so.
|
|
90
|
+
class Occupied < StandardError
|
|
91
|
+
def initialize(directory)
|
|
92
|
+
super("#{directory} exists and no pin_all_from line maps it as a graph; " \
|
|
93
|
+
"move it aside — or remove it, if an interrupted run left it — and pin again")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
attr_reader :directory
|
|
98
|
+
|
|
99
|
+
def initialize(directory, importmap_path:)
|
|
100
|
+
@directory, @importmap_path = Pathname.new(directory), Pathname.new(importmap_path)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# +to:+ is what makes the keys resolve: the directory is named for the pin
|
|
104
|
+
# that owns it, so without it Importmap::Map builds the asset path out of
|
|
105
|
+
# +under:+ and every file 404s. It is omitted only when the two agree.
|
|
106
|
+
def line_for(under:, version:, options: "")
|
|
107
|
+
to = directory.basename.to_s == under ? "" : %(, to: "#{directory.basename}")
|
|
108
|
+
|
|
109
|
+
%(pin_all_from "#{directory}", under: "#{under}") + to + options +
|
|
110
|
+
%( # @#{version.to_s.delete_prefix("@")} (#{GRAPH_DETAIL} #{under}))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def line_regexp
|
|
114
|
+
self.class.line_regexp_for(directory)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Whether the import map maps this directory, which is also how this class
|
|
118
|
+
# knows the directory is one the gem wrote rather than one the app keeps.
|
|
119
|
+
def mapped?
|
|
120
|
+
@importmap_path.exist? && importmap.match?(line_regexp)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Writes every file of +graph+ into a partial directory beside the target,
|
|
124
|
+
# each source through the block. Nothing the app has is touched until #commit.
|
|
125
|
+
def write(graph, &transform)
|
|
126
|
+
partial = Pathname.new("#{directory}.#{Process.pid}.download")
|
|
127
|
+
FileUtils.rm_rf partial
|
|
128
|
+
FileUtils.mkdir_p partial
|
|
129
|
+
written = false
|
|
130
|
+
|
|
131
|
+
begin
|
|
132
|
+
write_files(graph, partial, &transform)
|
|
133
|
+
written = true
|
|
134
|
+
ensure
|
|
135
|
+
# Cleaned up here rather than by the caller: a minifier that gives up on
|
|
136
|
+
# the twentieth of forty-seven files raises before the partial's path has
|
|
137
|
+
# been handed back, and nothing else knows the name to remove. In ensure
|
|
138
|
+
# rather than rescue because Ctrl-C during a 250-file write is the likely
|
|
139
|
+
# way this ends, and Interrupt is not a StandardError — the directory it
|
|
140
|
+
# would leave carries a pid that is gone, so no later run cleans it.
|
|
141
|
+
FileUtils.rm_rf partial unless written
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
partial
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Swaps a prepared directory over the one the app has: the old one is renamed
|
|
148
|
+
# aside and removed only once the new one is in place, so the window in which
|
|
149
|
+
# the app has neither is a rename rather than a recursive delete, and a
|
|
150
|
+
# failure halfway puts the old one back.
|
|
151
|
+
#
|
|
152
|
+
# Nothing prepared means the download came back as one file, and the
|
|
153
|
+
# directory a previous one left goes with it. Either way a directory the
|
|
154
|
+
# import map doesn't map as ours is the app's, and is never touched.
|
|
155
|
+
def commit(partial)
|
|
156
|
+
return mapped? ? remove_directory : nil unless partial
|
|
157
|
+
raise Occupied.new(directory) if directory.exist? && !mapped?
|
|
158
|
+
|
|
159
|
+
previous = Pathname.new("#{directory}.#{Process.pid}.previous")
|
|
160
|
+
FileUtils.rm_rf previous
|
|
161
|
+
|
|
162
|
+
begin
|
|
163
|
+
File.rename(directory, previous) if directory.exist?
|
|
164
|
+
File.rename(partial, directory)
|
|
165
|
+
ensure
|
|
166
|
+
# In ensure, not rescue: between the two renames the app has neither
|
|
167
|
+
# directory, and Ctrl-C there is not a StandardError — including one
|
|
168
|
+
# that lands as the first rename returns. Whether the swap happened is
|
|
169
|
+
# read off the disk — rename either put the directory in place or it
|
|
170
|
+
# didn't — rather than off a flag set after it returned.
|
|
171
|
+
if directory.exist?
|
|
172
|
+
FileUtils.rm_rf previous
|
|
173
|
+
elsif previous.exist?
|
|
174
|
+
File.rename(previous, directory)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Drops the directory and its line together, so nothing is left mapping files
|
|
180
|
+
# nothing imports any more. A directory the import map doesn't name is not
|
|
181
|
+
# this gem's to delete.
|
|
182
|
+
def remove
|
|
183
|
+
return false unless mapped?
|
|
184
|
+
|
|
185
|
+
remove_directory
|
|
186
|
+
|
|
187
|
+
lines = File.readlines(@importmap_path).grep_v(line_regexp)
|
|
188
|
+
File.open(@importmap_path, "w") { |file| lines.each { |line| file.write(line) } }
|
|
189
|
+
|
|
190
|
+
true
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def remove_directory
|
|
194
|
+
FileUtils.rm_rf directory
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
private
|
|
198
|
+
def write_files(graph, partial, &transform)
|
|
199
|
+
graph.files.each do |path, source|
|
|
200
|
+
file = partial.join(path)
|
|
201
|
+
# PackageGraph::PATH_REGEXP has already refused anything that could
|
|
202
|
+
# leave the directory; this is the assertion of it, because the paths
|
|
203
|
+
# come from a CDN and this is where they become a write.
|
|
204
|
+
raise Importmap::PackageGraph::Unownable.new(Importmap::PackageGraph::REASON) unless file.to_s.start_with?("#{partial}/")
|
|
205
|
+
|
|
206
|
+
FileUtils.mkdir_p file.dirname
|
|
207
|
+
File.write(file, transform ? transform.call(source) : source)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def importmap
|
|
212
|
+
File.read(@importmap_path)
|
|
213
|
+
end
|
|
214
|
+
end
|
data/lib/importmap/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: importmap-plus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Heinemeier Hansson
|
|
@@ -70,14 +70,25 @@ files:
|
|
|
70
70
|
- app/helpers/importmap/importmap_tags_helper.rb
|
|
71
71
|
- lib/importmap-plus.rb
|
|
72
72
|
- lib/importmap-rails.rb
|
|
73
|
+
- lib/importmap/batch_resolver.rb
|
|
73
74
|
- lib/importmap/commands.rb
|
|
75
|
+
- lib/importmap/doctor.rb
|
|
76
|
+
- lib/importmap/early_hints.rb
|
|
74
77
|
- lib/importmap/engine.rb
|
|
78
|
+
- lib/importmap/esm_run.rb
|
|
79
|
+
- lib/importmap/graph.rb
|
|
75
80
|
- lib/importmap/http_retries.rb
|
|
81
|
+
- lib/importmap/import_scanner.rb
|
|
82
|
+
- lib/importmap/integrity.rb
|
|
76
83
|
- lib/importmap/map.rb
|
|
77
84
|
- lib/importmap/minifier.rb
|
|
85
|
+
- lib/importmap/module_inspector.rb
|
|
78
86
|
- lib/importmap/npm.rb
|
|
87
|
+
- lib/importmap/package_graph.rb
|
|
79
88
|
- lib/importmap/packager.rb
|
|
89
|
+
- lib/importmap/provider_chain.rb
|
|
80
90
|
- lib/importmap/reloader.rb
|
|
91
|
+
- lib/importmap/vendored_graph.rb
|
|
81
92
|
- lib/importmap/version.rb
|
|
82
93
|
- lib/install/bin/importmap
|
|
83
94
|
- lib/install/config/importmap.rb
|