diff --git a/lib/importmap/commands.rb b/lib/importmap/commands.rb index f56abe1..ccd1ac9 100644 --- a/lib/importmap/commands.rb +++ b/lib/importmap/commands.rb @@ -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 @@ -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 @@ -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 diff --git a/lib/importmap/map.rb b/lib/importmap/map.rb index c88063b..37d304b 100644 --- a/lib/importmap/map.rb +++ b/lib/importmap/map.rb @@ -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 diff --git a/lib/importmap/npm.rb b/lib/importmap/npm.rb index 7c78fd0..aa604ae 100644 --- a/lib/importmap/npm.rb +++ b/lib/importmap/npm.rb @@ -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) @@ -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)) @@ -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/core@2.0.0-beta.19/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| @@ -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 diff --git a/lib/importmap/packager.rb b/lib/importmap/packager.rb index 1873091..638ad3b 100644 --- a/lib/importmap/packager.rb +++ b/lib/importmap/packager.rb @@ -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) @@ -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) @@ -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}") @@ -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) } diff --git a/test/commands_test.rb b/test/commands_test.rb index bdca613..00e6b05 100644 --- a/test/commands_test.rb +++ b/test/commands_test.rb @@ -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/md5@2.2.0", 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/md5@2.2.0", 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/md5@2.2.0", 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/md5@2.2.0", 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/md5@2.2.0", 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/md5@2.2.0', 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/md5@2.2.0", 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/md5@2.2.0", 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 diff --git a/test/packager_test.rb b/test/packager_test.rb index 032d069..127c6c2 100644 --- a/test/packager_test.rb +++ b/test/packager_test.rb @@ -79,4 +79,137 @@ def code() "200" end assert_equal %(pin "react", preload: "foo" # @17.0.2), @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["foo"]) assert_equal %(pin "react", preload: ["foo", "bar"] # @17.0.2), @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["foo", "bar"]) end + + test "extract_existing_pin_options with preload false" do + temp_importmap = create_temp_importmap('pin "package1", preload: false') + packager = Importmap::Packager.new(temp_importmap) + + options = extract_options_for_package(packager, "package1") + + assert_equal({ preload: false }, options) + end + + test "extract_existing_pin_options with preload true" do + temp_importmap = create_temp_importmap('pin "package1", preload: true') + packager = Importmap::Packager.new(temp_importmap) + + options = extract_options_for_package(packager, "package1") + + assert_equal({ preload: true }, options) + end + + test "extract_existing_pin_options with custom preload string" do + temp_importmap = create_temp_importmap('pin "package1", preload: "custom"') + packager = Importmap::Packager.new(temp_importmap) + + options = extract_options_for_package(packager, "package1") + + assert_equal({ preload: "custom" }, options) + end + + test "extract_existing_pin_options with custom preload array" do + temp_importmap = create_temp_importmap('pin "package1", preload: ["custom1", "custom2"]') + packager = Importmap::Packager.new(temp_importmap) + + options = extract_options_for_package(packager, "package1") + + assert_equal({ preload: ["custom1", "custom2"] }, options) + end + + test "extract_existing_pin_options with to option only" do + temp_importmap = create_temp_importmap('pin "package1", to: "custom_path.js"') + packager = Importmap::Packager.new(temp_importmap) + + options = extract_options_for_package(packager, "package1") + + assert_equal({}, options) + end + + test "extract_existing_pin_options with integrity option only" do + temp_importmap = create_temp_importmap('pin "package1", integrity: "sha384-abcdef1234567890"') + packager = Importmap::Packager.new(temp_importmap) + + options = extract_options_for_package(packager, "package1") + + assert_equal({}, options) + end + + test "extract_existing_pin_options with multiple options" do + temp_importmap = create_temp_importmap('pin "package1", to: "path.js", preload: false, integrity: "sha384-abcdef1234567890"') + packager = Importmap::Packager.new(temp_importmap) + + options = extract_options_for_package(packager, "package1") + + assert_equal({ preload: false }, options) + end + + test "extract_existing_pin_options with version comment" do + temp_importmap = create_temp_importmap('pin "package1", preload: false # @2.0.0') + packager = Importmap::Packager.new(temp_importmap) + + options = extract_options_for_package(packager, "package1") + + assert_equal({ preload: false }, options) + end + + test "extract_existing_pin_options with no options" do + temp_importmap = create_temp_importmap('pin "package1"') + packager = Importmap::Packager.new(temp_importmap) + + options = extract_options_for_package(packager, "package1") + + assert_equal({}, options) + end + + test "extract_existing_pin_options with nonexistent package" do + temp_importmap = create_temp_importmap('pin "package1", preload: false') + packager = Importmap::Packager.new(temp_importmap) + + options = extract_options_for_package(packager, "nonexistent") + + assert_equal({}, options) + end + + test "extract_existing_pin_options with nonexistent file" do + packager = Importmap::Packager.new("/nonexistent/path") + + options = extract_options_for_package(packager, "package1") + + assert_nil options + end + + test "extract_existing_pin_options handles multiple packages in one call" do + temp_importmap = create_temp_importmap(<<~PINS) + pin "package1", preload: false + pin "package2", preload: true + pin "package3", preload: "custom" + pin "package4" # no options + PINS + + packager = Importmap::Packager.new(temp_importmap) + + result = packager.extract_existing_pin_options(["package1", "package2", "package3", "package4", "nonexistent"]) + + assert_equal({ + "package1" => { preload: false }, + "package2" => { preload: true }, + "package3" => { preload: "custom" }, + "package4" => {}, + "nonexistent" => {} + }, result) + end + + private + + def create_temp_importmap(content) + temp_file = Tempfile.new(['importmap', '.rb']) + temp_file.write(content) + temp_file.close + temp_file.path + end + + def extract_options_for_package(packager, package_name) + result = packager.extract_existing_pin_options(package_name) + result[package_name] + end end