Skip to content

Keep options when updating packages in importmap #310

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jul 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions lib/importmap/commands.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,7 @@ def self.exit_on_failure?
option :preload, type: :string, repeatable: true, desc: "Can be used multiple times"
def pin(*packages)
for_each_import(packages, env: options[:env], from: options[:from]) do |package, url|
puts %(Pinning "#{package}" to #{packager.vendor_path}/#{package}.js via download from #{url})

packager.download(package, url)

pin = packager.vendored_pin_for(package, url, options[:preload])

update_importmap_with_pin(package, pin)
pin_package(package, url, options[:preload])
end
end

Expand Down Expand Up @@ -96,7 +90,14 @@ def outdated
desc "update", "Update outdated package pins"
def update
if (outdated_packages = npm.outdated_packages).any?
pin(*outdated_packages.map(&:name))
package_names = outdated_packages.map(&:name)
packages_with_options = packager.extract_existing_pin_options(package_names)

for_each_import(package_names, env: "production", from: "jspm") do |package, url|
options = packages_with_options[package] || {}

pin_package(package, url, options[:preload])
end
else
puts "No outdated packages found"
end
Expand All @@ -116,11 +117,23 @@ def npm
@npm ||= Importmap::Npm.new
end

def pin_package(package, url, preload)
puts %(Pinning "#{package}" to #{packager.vendor_path}/#{package}.js via download from #{url})

packager.download(package, url)

pin = packager.vendored_pin_for(package, url, preload)

update_importmap_with_pin(package, pin)
end

def update_importmap_with_pin(package, pin)
new_pin = "#{pin}\n"

if packager.packaged?(package)
gsub_file("config/importmap.rb", /^pin "#{package}".*$/, pin, verbose: false)
gsub_file("config/importmap.rb", Importmap::Map.pin_line_regexp_for(package), new_pin, verbose: false)
else
append_to_file("config/importmap.rb", "#{pin}\n", verbose: false)
append_to_file("config/importmap.rb", new_pin, verbose: false)
end
end

Expand Down
6 changes: 6 additions & 0 deletions lib/importmap/map.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
class Importmap::Map
attr_reader :packages, :directories

PIN_REGEX = /^pin\s+["']([^"']+)["']/.freeze # :nodoc:

def self.pin_line_regexp_for(package) # :nodoc:
/^.*pin\s+["']#{Regexp.escape(package)}["'].*$/.freeze
end

class InvalidFile < StandardError; end

def initialize
Expand Down
8 changes: 4 additions & 4 deletions lib/importmap/npm.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
require "json"

class Importmap::Npm
PIN_REGEX = /^pin ["']([^["']]*)["'].*/
PIN_REGEX = /#{Importmap::Map::PIN_REGEX}.*/.freeze # :nodoc:

Error = Class.new(StandardError)
HTTPError = Class.new(Error)
Expand All @@ -17,7 +17,7 @@ def initialize(importmap_path = "config/importmap.rb", vendor_path: "vendor/java
end

def outdated_packages
packages_with_versions.each.with_object([]) do |(package, current_version), outdated_packages|
packages_with_versions.each_with_object([]) do |(package, current_version), outdated_packages|
outdated_package = OutdatedPackage.new(name: package, current_version: current_version)

if !(response = get_package(package))
Expand Down Expand Up @@ -51,7 +51,7 @@ def vulnerable_packages
def packages_with_versions
# We cannot use the name after "pin" because some dependencies are loaded from inside packages
# Eg. pin "buffer", to: "https://ga.jspm.io/npm:@jspm/[email protected]/nodelibs/browser/buffer.js"
with_versions = importmap.scan(/^pin .*(?<=npm:|npm\/|skypack\.dev\/|unpkg\.com\/)(.*)(?=@\d+\.\d+\.\d+)@(\d+\.\d+\.\d+(?:[^\/\s["']]*)).*$/) |
with_versions = importmap.scan(/^pin .*(?<=npm:|npm\/|skypack\.dev\/|unpkg\.com\/)([^@\/]+)@(\d+\.\d+\.\d+(?:[^\/\s"']*))/) |
importmap.scan(/#{PIN_REGEX} #.*@(\d+\.\d+\.\d+(?:[^\s]*)).*$/)

vendored_packages_without_version(with_versions).each do |package, path|
Expand Down Expand Up @@ -147,7 +147,7 @@ def vendored_packages_without_version(packages_with_versions)
end

def find_unversioned_vendored_package(line, versioned_packages)
regexp = line.include?("to:")? /#{PIN_REGEX}to: ["']([^["']]*)["'].*/ : PIN_REGEX
regexp = line.include?("to:")? /#{PIN_REGEX}to: ["']([^"']*)["'].*/ : PIN_REGEX
match = line.match(regexp)

return unless match
Expand Down
54 changes: 50 additions & 4 deletions lib/importmap/packager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
require "json"

class Importmap::Packager
PIN_REGEX = /#{Importmap::Map::PIN_REGEX}(.*)/.freeze # :nodoc:
PRELOAD_OPTION_REGEXP = /preload:\s*(\[[^\]]+\]|true|false|["'][^"']*["'])/.freeze # :nodoc:

Error = Class.new(StandardError)
HTTPError = Class.new(Error)
ServiceError = Error.new(Error)
Expand Down Expand Up @@ -51,7 +54,7 @@ def vendored_pin_for(package, url, preloads = nil)
end

def packaged?(package)
importmap.match(/^pin ["']#{package}["'].*$/)
importmap.match(Importmap::Map.pin_line_regexp_for(package))
end

def download(package, url)
Expand All @@ -65,14 +68,57 @@ def remove(package)
remove_package_from_importmap(package)
end

def extract_existing_pin_options(packages)
return {} unless @importmap_path.exist?

packages = Array(packages)

all_package_options = build_package_options_lookup(importmap.lines)

packages.to_h do |package|
[package, all_package_options[package] || {}]
end
end

private
def build_package_options_lookup(lines)
lines.each_with_object({}) do |line, package_options|
match = line.strip.match(PIN_REGEX)

if match
package_name = match[1]
options_part = match[2]

preload_match = options_part.match(PRELOAD_OPTION_REGEXP)

if preload_match
preload = preload_from_string(preload_match[1])
package_options[package_name] = { preload: preload }
end
end
end
end

def preload_from_string(value)
case value
when "true"
true
when "false"
false
when /^\[.*\]$/
JSON.parse(value)
else
value.gsub(/["']/, "")
end
end

def preload(preloads)
case Array(preloads)
in []
""
in ["true"]
in ["true"] | [true]
%(, preload: true)
in ["false"]
in ["false"] | [false]
%(, preload: false)
in [string]
%(, preload: "#{string}")
Expand Down Expand Up @@ -129,7 +175,7 @@ def remove_existing_package_file(package)

def remove_package_from_importmap(package)
all_lines = File.readlines(@importmap_path)
with_lines_removed = all_lines.grep_v(/pin ["']#{package}["']/)
with_lines_removed = all_lines.grep_v(Importmap::Map.pin_line_regexp_for(package))

File.open(@importmap_path, "w") do |file|
with_lines_removed.each { |line| file.write(line) }
Expand Down
98 changes: 98 additions & 0 deletions test/commands_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,105 @@ class CommandsTest < ActiveSupport::TestCase
assert_equal original, File.read("#{@tmpdir}/dummy/vendor/javascript/md5.js")
end

test "update command preserves preload false option" do
importmap_config('pin "md5", to: "https://cdn.skypack.dev/[email protected]", preload: false')

out, _err = run_importmap_command("update")

assert_includes out, "Pinning"

updated_content = File.read("#{@tmpdir}/dummy/config/importmap.rb")
assert_includes updated_content, "preload: false"
assert_includes updated_content, "# @2.3.0"
end

test "update command preserves preload true option" do
importmap_config('pin "md5", to: "https://cdn.skypack.dev/[email protected]", preload: true')

out, _err = run_importmap_command("update")

assert_includes out, "Pinning"

updated_content = File.read("#{@tmpdir}/dummy/config/importmap.rb")
assert_includes updated_content, "preload: true"
end

test "update command preserves custom preload string option" do
importmap_config('pin "md5", to: "https://cdn.skypack.dev/[email protected]", preload: "custom"')

out, _err = run_importmap_command("update")

assert_includes out, "Pinning"

updated_content = File.read("#{@tmpdir}/dummy/config/importmap.rb")
assert_includes updated_content, 'preload: "custom"'
end

test "update command removes existing integrity" do
importmap_config('pin "md5", to: "https://cdn.skypack.dev/[email protected]", integrity: "sha384-oldintegrity"')

out, _err = run_importmap_command("update")

assert_includes out, "Pinning"

updated_content = File.read("#{@tmpdir}/dummy/config/importmap.rb")
assert_not_includes updated_content, "integrity:"
end

test "update command only keeps preload option" do
importmap_config('pin "md5", to: "https://cdn.skypack.dev/[email protected]", preload: false, integrity: "sha384-oldintegrity"')

out, _err = run_importmap_command("update")

assert_includes out, "Pinning"

updated_content = File.read("#{@tmpdir}/dummy/config/importmap.rb")
assert_includes updated_content, "preload: false"
assert_not_includes updated_content, "to:"
assert_not_includes updated_content, "integrity:"
end

test "update command handles packages with different quote styles" do
importmap_config("pin 'md5', to: 'https://cdn.skypack.dev/[email protected]', preload: false")

out, _err = run_importmap_command("update")

assert_includes out, "Pinning"

updated_content = File.read("#{@tmpdir}/dummy/config/importmap.rb")
assert_includes updated_content, "preload: false"
end

test "update command preserves options with version comments" do
importmap_config('pin "md5", to: "https://cdn.skypack.dev/[email protected]", preload: false # @2.2.0')

out, _err = run_importmap_command("update")

assert_includes out, "Pinning"

updated_content = File.read("#{@tmpdir}/dummy/config/importmap.rb")
assert_includes updated_content, "preload: false"
assert_includes updated_content, "# @2.3.0"
assert_not_includes updated_content, "# @2.2.0"
end

test "update command handles whitespace variations in pin options" do
importmap_config('pin "md5", to: "https://cdn.skypack.dev/[email protected]", preload: false ')

out, _err = run_importmap_command("update")

assert_includes out, "Pinning"

updated_content = File.read("#{@tmpdir}/dummy/config/importmap.rb")
assert_equal 4, updated_content.lines.size
assert_includes updated_content, "preload: false"
end

private
def importmap_config(content)
File.write("#{@tmpdir}/dummy/config/importmap.rb", content)
end

def run_importmap_command(command, *args)
capture_subprocess_io { system("bin/importmap", command, *args, exception: true) }
end
Expand Down
Loading