importmap-plus 2.0.0 → 2.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a89ba9297f9e04e1f3a892ea319d509ce9300789f648327c3b4b1cf72c0951d7
4
- data.tar.gz: c088e5ab5732c9f132bfa63a60d2a7d9243ac85690670e1a040e3658fc1fb091
3
+ metadata.gz: '068b0fd351d34f6741037740731f5545ec5e98530ddd935abc45d18ac92b30e1'
4
+ data.tar.gz: 6482173bb6db3322fa43711fce8ccf0c9ac48d7b35c0b572e5b68d45f11d0445
5
5
  SHA512:
6
- metadata.gz: 81f2d28ee6c2d724917e338451d4a0f2baef86feb1bcc5477feca8114f13213787d9164b6befc8e7e02cf24e5ec0f810e13d05195be4e27c689bc70db5fd9311
7
- data.tar.gz: 3cbd693b24887409203b8f75294798c3ee4b08dec82a90eb404de2a24e7ba7f273d4babd48a72c3a1608f8f263b4804b27dcfbf624edbb69252ea8ea6b346d63
6
+ metadata.gz: 22b1de80057a17f315ab8230d5537400985c9f67c1c50faa184336856de9e3e1d8f06ea815392c78a0fef30bffa3373bd25b206ab638cf056c8447faa674fe44
7
+ data.tar.gz: 444fca5f51ec92e43ab7163bb14ce92791727b3a93242298e776a753cc243e218f17fdf3acb6c873842012bf38867862fa37bf4eb99e0464ed8e72061254c33f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ### Fixed
6
+
7
+ - **`doctor` leaves a bundle installed inside the app alone.** A file an engine
8
+ serves out of its gem is skipped, because no `bin/importmap` command can fix
9
+ it — but "out of its gem" was read as "outside the app root". With
10
+ `bundle config set path vendor/bundle`, as a CI caching its gems has, the
11
+ gems are under the root, so an engine's controller importing a package the
12
+ app never pinned failed `doctor` in CI and nowhere else. Files under
13
+ `Gem.path` or `Bundler.bundle_path` are now skipped wherever they sit.
14
+
15
+ - **A `preload: %w[...]` survives a rewrite.** `pin`, `update` and `pristine`
16
+ carried over `preload: ["admin", "app"]` but not the same list written as a
17
+ word array, which RuboCop and Standard prefer (`Style/WordArray`). The option
18
+ was dropped without a word, so an updated package quietly stopped being
19
+ preloaded on the pages that named it. It is now read — backslash escapes and
20
+ nested brackets included, so `%w[my\ app]` is the one entry point Ruby sees and
21
+ `%w[foo [bar]]` keeps its `[bar]` — and written back in the form the app chose:
22
+
23
+ ```ruby
24
+ pin "tailwindcss-stimulus-components", preload: %w[application webcad] # @6.1.3
25
+ # after bin/importmap update
26
+ pin "tailwindcss-stimulus-components", preload: %w[application webcad] # @6.1.4
27
+ ```
28
+
29
+ - **A quoted preload keeps a bracket or quote inside it.** `preload: ["a]", "b"]`
30
+ was cut at the first `]` and `preload: "it's"` at the `'`, so a rewrite wrote
31
+ back a shorter list or a different entry point. Quoted strings are now read
32
+ whole, and a quoted preload — one entry point or a bracketed list — is written
33
+ back exactly as the app wrote it, quotes included, so `"\u0061pp"` stays the
34
+ `app` entry point and `"#{prefix}app"` still interpolates (both used to come
35
+ back as a different name). `preload: 'say "hi"'` no longer becomes `"say "hi""`.
36
+
37
+ ## 2.0.1
38
+
39
+ ### Fixed
40
+
41
+ - **`bin/importmap doctor` no longer reports comments and prose as unpinned
42
+ imports.** The import scanner read `from "…"` and `import "…"` wherever the
43
+ text appeared, so a line comment such as `// e.g. 'en' from 'en-US'` and
44
+ strings such as turbo_power's `` `…provided in the "from" attribute for
45
+ the "…` `` came back as `error … imports " attribute for the ", which isn't
46
+ pinned`. Line comments are now discounted before scanning (strings and
47
+ regex literals are still consumed whole, so `"https://…"` is safe), and a
48
+ quoted text that can't be a module specifier — whitespace, a backtick, a
49
+ `${` interpolation, or no word character at all — is not reported.
50
+
3
51
  ## 1.2.0
4
52
 
5
53
  ### Added
@@ -59,11 +59,18 @@ class Importmap::Doctor
59
59
  # against the importing file's own URL, or fetches outright, is not one.
60
60
  SCHEME_REGEXP = %r{\A[a-zA-Z][a-zA-Z0-9+\-.]*:}.freeze # :nodoc:
61
61
 
62
- def initialize(importmap:, resolver:, root:, asset_paths: [], online: false)
62
+ # Where installed gems live: the default +gem_paths+. A bundle installed
63
+ # with `path: vendor/bundle` puts Bundler's under the app root.
64
+ def self.gem_paths
65
+ [ *Gem.path, (Bundler.bundle_path.to_s if defined?(Bundler)) ].compact.uniq
66
+ end
67
+
68
+ def initialize(importmap:, resolver:, root:, asset_paths: [], gem_paths: self.class.gem_paths, online: false)
63
69
  @importmap = importmap
64
70
  @resolver = resolver
65
71
  @root = Pathname.new(root)
66
72
  @asset_paths = Array(asset_paths).map { |path| Pathname.new(path) }
73
+ @gem_paths = Array(gem_paths).map { |path| Pathname.new(path) }
67
74
  @online = online
68
75
  end
69
76
 
@@ -188,9 +195,14 @@ class Importmap::Doctor
188
195
 
189
196
  # The files this gem may read: the app's own, not a file an engine serves
190
197
  # out of its gem, which no `bin/importmap` command can do anything about.
191
- # One file serving two keys is scanned once.
198
+ # Being under the root isn't enough to be the app's: a bundle installed
199
+ # into vendor/bundle is too. One file serving two keys is scanned once.
192
200
  def scannable
193
- @scannable ||= entries.select { |entry| entry.file && under?(entry.file, @root) }.uniq(&:file)
201
+ @scannable ||= entries.select { |entry| entry.file && app_file?(entry.file) }.uniq(&:file)
202
+ end
203
+
204
+ def app_file?(file)
205
+ under?(file, @root) && @gem_paths.none? { |gem_path| under?(file, gem_path) }
194
206
  end
195
207
 
196
208
  def vendored
@@ -6,16 +6,19 @@ require "importmap/module_inspector"
6
6
  #
7
7
  # Like Importmap::ModuleInspector and Importmap::PackageGraph::IMPORT_REGEXP
8
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
9
+ # import statement spelled out inside a string literal is reported too — unless
10
+ # what it names can't be a module specifier at all — and a form the regexp
11
+ # can't read — a magic comment between the keyword and the
11
12
  # specifier, a specifier built at runtime — isn't reported at all. Both
12
13
  # mistakes are the cautious direction for the one caller: a specifier reported
13
14
  # that the browser never asks for costs a pin, and a specifier missed leaves
14
15
  # the app exactly as broken as it was before anyone ran the check.
15
16
  #
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.
17
+ # Comments are discounted first, through
18
+ # ModuleInspector#code_without_line_comments, because a published bundle is
19
+ # full of `/** @typedef {import('./slide.js').Slide} */` — type annotations
20
+ # naming files the package never loads — and app code is full of prose like
21
+ # `// 'en' from 'en-US'`.
19
22
  class Importmap::ImportScanner
20
23
  Import = Struct.new(:specifier, :kind, keyword_init: true) # :nodoc:
21
24
 
@@ -38,6 +41,12 @@ class Importmap::ImportScanner
38
41
  (?:from|import)\s*(["'])([^"'\n]*)\3
39
42
  )
40
43
  /x.freeze # :nodoc:
44
+ # What a quoted `from "…"` or `import "…"` has to hold to name a module
45
+ # rather than be prose the regexp above found in a string: something, no
46
+ # whitespace or backtick, no `${` interpolation, and at least one word
47
+ # character — `"from" attribute for the "`, `["import",","]` and
48
+ # `` `import("${url}")` `` are all strings, never specifiers.
49
+ SPECIFIER_REGEXP = /\A(?!.*\$\{)[^\s`]*\w[^\s`]*\z/.freeze # :nodoc:
41
50
 
42
51
  attr_reader :source
43
52
 
@@ -46,7 +55,9 @@ class Importmap::ImportScanner
46
55
  end
47
56
 
48
57
  def imports
49
- @imports ||= Importmap::ModuleInspector.new(source).code.scan(IMPORT_REGEXP).map do |_, dynamic, _, static|
58
+ @imports ||= Importmap::ModuleInspector.new(source).code_without_line_comments.scan(IMPORT_REGEXP).filter_map do |_, dynamic, _, static|
59
+ next unless (dynamic || static).match?(SPECIFIER_REGEXP)
60
+
50
61
  dynamic ? Import.new(specifier: dynamic, kind: :dynamic) : Import.new(specifier: static, kind: :static)
51
62
  end
52
63
  end
@@ -144,6 +144,14 @@ class Importmap::ModuleInspector
144
144
  @code ||= without_block_comments
145
145
  end
146
146
 
147
+ # #code with its line comments discounted too, for Importmap::ImportScanner:
148
+ # prose such as `// e.g. 'en' from 'en-US'` reads as an import otherwise.
149
+ # Strings and regex literals are still consumed whole first, so the `//` of
150
+ # `"https://…"` or `/\/\//` never opens a comment.
151
+ def code_without_line_comments
152
+ @code_without_line_comments ||= without_block_comments(line_comments: true)
153
+ end
154
+
147
155
  private
148
156
  def statements
149
157
  @statements ||= without_block_comments(statements_only: true)
@@ -170,12 +178,15 @@ class Importmap::ModuleInspector
170
178
  # reads as an import statement while the text inside it stops being read
171
179
  # as code at all. That mode feeds only the ES-module check, where the
172
180
  # `[//]` trap above can at worst send a package on to the next CDN.
173
- def without_block_comments(statements_only: false)
181
+ # With +line_comments+ alone they go but literals are kept, for the import
182
+ # scanner: the trap there can at worst miss an import in the doctor's
183
+ # report, where prose in a comment is a false error on every run.
184
+ def without_block_comments(statements_only: false, line_comments: statements_only)
174
185
  scanner = StringScanner.new(source)
175
186
  kept = +""
176
187
 
177
188
  until scanner.eos?
178
- if scanner.skip(BLOCK_COMMENT_REGEXP) || (statements_only && scanner.skip(LINE_COMMENT_REGEXP))
189
+ if scanner.skip(BLOCK_COMMENT_REGEXP) || (line_comments && scanner.skip(LINE_COMMENT_REGEXP))
179
190
  next
180
191
  elsif (literal = scanner.scan(STRING_REGEXP)) ||
181
192
  (regexp_literal_next?(kept, scanner) && (literal = scanner.scan(REGEXP_LITERAL_REGEXP)))
@@ -13,10 +13,47 @@ class Importmap::Packager
13
13
  include Importmap::HttpRetries
14
14
 
15
15
  PIN_REGEX = /#{Importmap::Map::PIN_REGEX}(.*)/.freeze # :nodoc:
16
+ # A Ruby string literal, escapes included, so a quote or bracket inside one
17
+ # doesn't end the match early (`"it's"`, `["a]"]`).
18
+ QUOTED_STRING_REGEXP = /"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'/m.freeze # :nodoc:
16
19
  # The bracketed form matches an empty array too: `preload: []` is a pin an
17
20
  # app wrote, and a rewrite that dropped it would start preloading the package
18
- # on every page.
19
- PRELOAD_OPTION_REGEXP = /preload:\s*(\[[^\]]*\]|true|false|["'][^"']*["'])/.freeze # :nodoc:
21
+ # on every page. A word array nests its own delimiter the way Ruby does
22
+ # (`%w[foo [bar]]` is "foo" and "[bar]"), hence the recursive groups; with
23
+ # named groups in the pattern, the value has to be named too to stay [1].
24
+ PRELOAD_OPTION_REGEXP = /preload:\s*(?<value>\[(?:#{QUOTED_STRING_REGEXP}|[^\]"'])*\]|%w(?<brackets>\[(?:\\.|[^\[\]\\]|\g<brackets>)*\])|%w(?<parens>\((?:\\.|[^()\\]|\g<parens>)*\))|true|false|#{QUOTED_STRING_REGEXP})/.freeze # :nodoc:
25
+ # A `preload: %w[...]` read from a pin, so a rewrite writes it back in the
26
+ # form the app chose (RuboCop's Style/WordArray flags `["a", "b"]`). Read
27
+ # with Ruby's escapes rather than split on whitespace: `%w[my\ app]` is one
28
+ # word, and a backslash before anything but whitespace, a backslash or the
29
+ # literal's own delimiter stays in it.
30
+ class WordArray < Array # :nodoc:
31
+ LITERAL_REGEXP = /\A%w([\[(])(.*)[\])]\z/m.freeze
32
+
33
+ def self.parse(literal)
34
+ open, body = literal.match(LITERAL_REGEXP)&.captures
35
+ return unless open
36
+
37
+ escapable = /\\([\s\\#{Regexp.escape(open == "[" ? "[]" : "()")}])/
38
+ new(body.scan(/(?:\\.|[^\s\\])+/m).map { |word| word.gsub(escapable, '\1') })
39
+ end
40
+
41
+ def to_s
42
+ "%w[#{map { |word| word.gsub(/[\s\\\[\]]/) { "\\#{$&}" } }.join(" ")}]"
43
+ end
44
+ end
45
+ # A quoted preload read from a pin — a string or a bracketed array — keeps
46
+ # the literal it was read from, and a rewrite writes that back untouched.
47
+ # The names are read with only the simple escapes; the rest of Ruby's string
48
+ # semantics ("\u0061pp", "#{prefix}app") is Ruby's to evaluate, so writing
49
+ # the value read back through inspect would change the entry point.
50
+ module Literal # :nodoc:
51
+ attr_accessor :source
52
+
53
+ def self.of(value, source)
54
+ value.extend(self).tap { |literal| literal.source = source }
55
+ end
56
+ end
20
57
  TO_OPTION_REGEXP = /to:\s*["']([^"']*)["']/.freeze # :nodoc:
21
58
  # Only the booleans: a hash string is tied to the file it was computed for,
22
59
  # so a rewrite that changes the URL has to drop it.
@@ -537,21 +574,33 @@ class Importmap::Packager
537
574
  true
538
575
  when "false"
539
576
  false
577
+ when WordArray::LITERAL_REGEXP
578
+ WordArray.parse(value)
540
579
  when /^\[.*\]$/
541
580
  # config/importmap.rb is Ruby, not JSON, and a single-quoted pin is a
542
581
  # supported shape here, so the entry points are scanned out of the
543
582
  # literal rather than parsed. JSON.parse raised on every one of them.
544
- value.scan(/["']([^"']*)["']/).flatten
583
+ Literal.of(value.scan(QUOTED_STRING_REGEXP).map { |double, single| unquote(double, single) }, value)
545
584
  else
546
- value.gsub(/["']/, "")
585
+ Literal.of(unquote(*value.match(/\A#{QUOTED_STRING_REGEXP}\z/).captures), value)
547
586
  end
548
587
  end
549
588
 
589
+ # A single-quoted string unescapes only \\ and \'. In a double-quoted one
590
+ # a backslash before any character an entry point would hold is that
591
+ # character, which is all a preload name needs. The value is only read;
592
+ # a rewrite writes the Literal's source.
593
+ def unquote(double, single)
594
+ double ? double.gsub(/\\(.)/m, '\1') : single.gsub(/\\([\\'])/, '\1')
595
+ end
596
+
550
597
  def preload(preloads)
551
598
  # nil is "the pin carries no preload:"; [] is the pin carrying one that
552
599
  # names no entry point. Array() flattens both to [], so the difference
553
600
  # has to be read before it.
554
601
  return "" if preloads.nil?
602
+ return %(, preload: #{preloads}) if preloads.is_a?(WordArray)
603
+ return %(, preload: #{preloads.source}) if preloads.is_a?(Literal)
555
604
 
556
605
  case Array(preloads)
557
606
  in []
@@ -561,7 +610,7 @@ class Importmap::Packager
561
610
  in ["false"] | [false]
562
611
  %(, preload: false)
563
612
  in [string]
564
- %(, preload: "#{string}")
613
+ %(, preload: #{string.to_s.inspect})
565
614
  else
566
615
  %(, preload: #{preloads})
567
616
  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 = "2.0.0"
4
+ VERSION = "2.0.2"
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: 2.0.0
4
+ version: 2.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Heinemeier Hansson