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,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
@@ -1,6 +1,6 @@
1
1
  module Importmap
2
2
  # importmap-plus versions independently of the importmap-rails it forks.
3
3
  # UPSTREAM_VERSION is the importmap-rails release this tracks.
4
- VERSION = "1.1.1"
4
+ VERSION = "2.0.0"
5
5
  UPSTREAM_VERSION = "2.2.3"
6
6
  end
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: 1.1.1
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