importmap-rails-2.2.3/0000755000004100000410000000000015145701236014672 5ustar www-datawww-dataimportmap-rails-2.2.3/importmap-rails.gemspec0000644000004100000410000000363215145701236021363 0ustar www-datawww-data######################################################### # This file has been automatically generated by gem2tgz # ######################################################### # -*- encoding: utf-8 -*- # stub: importmap-rails 2.2.3 ruby lib Gem::Specification.new do |s| s.name = "importmap-rails".freeze s.version = "2.2.3".freeze s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= s.metadata = { "homepage_uri" => "https://github.com/rails/importmap-rails", "source_code_uri" => "https://github.com/rails/importmap-rails" } if s.respond_to? :metadata= s.require_paths = ["lib".freeze] s.authors = ["David Heinemeier Hansson".freeze] s.date = "1980-01-02" s.email = "david@loudthinking.com".freeze s.files = ["MIT-LICENSE".freeze, "README.md".freeze, "Rakefile".freeze, "app/controllers/importmap/freshness.rb".freeze, "app/helpers/importmap/importmap_tags_helper.rb".freeze, "lib/importmap-rails.rb".freeze, "lib/importmap/commands.rb".freeze, "lib/importmap/engine.rb".freeze, "lib/importmap/map.rb".freeze, "lib/importmap/npm.rb".freeze, "lib/importmap/packager.rb".freeze, "lib/importmap/reloader.rb".freeze, "lib/importmap/version.rb".freeze, "lib/install/bin/importmap".freeze, "lib/install/config/importmap.rb".freeze, "lib/install/install.rb".freeze, "lib/tasks/importmap_tasks.rake".freeze] s.homepage = "https://github.com/rails/importmap-rails".freeze s.licenses = ["MIT".freeze] s.required_ruby_version = Gem::Requirement.new(">= 3.1.0".freeze) s.rubygems_version = "4.0.3".freeze s.summary = "Use ESM with importmap to manage modern JavaScript in Rails without transpiling or bundling.".freeze s.specification_version = 4 s.add_runtime_dependency(%q.freeze, [">= 6.0.0".freeze]) s.add_runtime_dependency(%q.freeze, [">= 6.0.0".freeze]) s.add_runtime_dependency(%q.freeze, [">= 6.0.0".freeze]) end importmap-rails-2.2.3/lib/0000755000004100000410000000000015145701236015440 5ustar www-datawww-dataimportmap-rails-2.2.3/lib/tasks/0000755000004100000410000000000015145701236016565 5ustar www-datawww-dataimportmap-rails-2.2.3/lib/tasks/importmap_tasks.rake0000644000004100000410000000043115145701236022644 0ustar www-datawww-datanamespace :importmap do desc "Setup Importmap for the app" task :install do previous_location = ENV["LOCATION"] ENV["LOCATION"] = File.expand_path("../install/install.rb", __dir__) Rake::Task["app:template"].invoke ENV["LOCATION"] = previous_location end end importmap-rails-2.2.3/lib/install/0000755000004100000410000000000015145701236017106 5ustar www-datawww-dataimportmap-rails-2.2.3/lib/install/bin/0000755000004100000410000000000015145701236017656 5ustar www-datawww-dataimportmap-rails-2.2.3/lib/install/bin/importmap0000755000004100000410000000013315145701236021611 0ustar www-datawww-data#!/usr/bin/env ruby require_relative "../config/application" require "importmap/commands" importmap-rails-2.2.3/lib/install/install.rb0000644000004100000410000000246215145701236021105 0ustar www-datawww-dataAPPLICATION_LAYOUT_PATH = Rails.root.join("app/views/layouts/application.html.erb") if APPLICATION_LAYOUT_PATH.exist? say "Add Importmap include tags in application layout" insert_into_file APPLICATION_LAYOUT_PATH.to_s, "\n <%= javascript_importmap_tags %>", before: /\s*<\/head>/ else say "Default application.html.erb is missing!", :red say " Add <%= javascript_importmap_tags %> within the tag in your custom layout." end say "Create application.js module as entrypoint" create_file Rails.root.join("app/javascript/application.js") do <<-JS // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails JS end say "Use vendor/javascript for downloaded pins" empty_directory "vendor/javascript" keep_file "vendor/javascript" if (sprockets_manifest_path = Rails.root.join("app/assets/config/manifest.js")).exist? say "Ensure JavaScript files are in the Sprocket manifest" append_to_file sprockets_manifest_path, %(//= link_tree ../../javascript .js\n//= link_tree ../../../vendor/javascript .js\n) end say "Configure importmap paths in config/importmap.rb" copy_file "#{__dir__}/config/importmap.rb", "config/importmap.rb" say "Copying binstub" copy_file "#{__dir__}/bin/importmap", "bin/importmap" chmod "bin", 0755 & ~File.umask, verbose: false importmap-rails-2.2.3/lib/install/config/0000755000004100000410000000000015145701236020353 5ustar www-datawww-dataimportmap-rails-2.2.3/lib/install/config/importmap.rb0000644000004100000410000000010115145701236022700 0ustar www-datawww-data# Pin npm packages by running ./bin/importmap pin "application" importmap-rails-2.2.3/lib/importmap/0000755000004100000410000000000015145701236017450 5ustar www-datawww-dataimportmap-rails-2.2.3/lib/importmap/reloader.rb0000644000004100000410000000076115145701236021576 0ustar www-datawww-datarequire "active_support" require "active_support/core_ext/module/delegation" class Importmap::Reloader delegate :execute_if_updated, :execute, :updated?, to: :updater def reload! import_map_paths.each { |path| Rails.application.importmap.draw(path) } end private def updater @updater ||= config.file_watcher.new(import_map_paths) { reload! } end def import_map_paths config.importmap.paths end def config Rails.application.config end end importmap-rails-2.2.3/lib/importmap/engine.rb0000755000004100000410000000465515145701236021257 0ustar www-datawww-datarequire "importmap/map" # Use Rails.application.importmap to access the map Rails::Application.send(:attr_accessor, :importmap) module Importmap class Engine < ::Rails::Engine config.importmap = ActiveSupport::OrderedOptions.new config.importmap.paths = [] config.importmap.sweep_cache = Rails.env.development? || Rails.env.test? config.importmap.cache_sweepers = [] config.importmap.rescuable_asset_errors = [] config.autoload_once_paths = %W( #{root}/app/helpers #{root}/app/controllers ) initializer "importmap" do |app| app.importmap = Importmap::Map.new app.config.importmap.paths << app.root.join("config/importmap.rb") app.config.importmap.paths.each { |path| app.importmap.draw(path) } end initializer "importmap.reloader" do |app| unless app.config.cache_classes Importmap::Reloader.new.tap do |reloader| reloader.execute app.reloaders << reloader app.reloader.to_run { reloader.execute } end end end initializer "importmap.cache_sweeper" do |app| if app.config.importmap.sweep_cache && !app.config.cache_classes app.config.importmap.cache_sweepers << app.root.join("app/javascript") app.config.importmap.cache_sweepers << app.root.join("vendor/javascript") app.importmap.cache_sweeper(watches: app.config.importmap.cache_sweepers) ActiveSupport.on_load(:action_controller_base) do before_action { Rails.application.importmap.cache_sweeper.execute_if_updated } end end end initializer "importmap.assets" do |app| if app.config.respond_to?(:assets) app.config.assets.paths << Rails.root.join("app/javascript") app.config.assets.paths << Rails.root.join("vendor/javascript") end end initializer "importmap.concerns" do ActiveSupport.on_load(:action_controller_base) do extend Importmap::Freshness end end initializer "importmap.helpers" do ActiveSupport.on_load(:action_controller_base) do helper Importmap::ImportmapTagsHelper end end initializer "importmap.rescuable_asset_errors" do |app| if defined?(Propshaft) app.config.importmap.rescuable_asset_errors << Propshaft::MissingAssetError end if defined?(Sprockets::Rails) app.config.importmap.rescuable_asset_errors << Sprockets::Rails::Helper::AssetNotFound end end end end importmap-rails-2.2.3/lib/importmap/npm.rb0000644000004100000410000001314715145701236020575 0ustar www-datawww-datarequire "net/http" require "uri" require "json" class Importmap::Npm PIN_REGEX = /#{Importmap::Map::PIN_REGEX}.*/.freeze # :nodoc: Error = Class.new(StandardError) HTTPError = Class.new(Error) singleton_class.attr_accessor :base_uri self.base_uri = URI("https://registry.npmjs.org") def initialize(importmap_path = "config/importmap.rb", vendor_path: "vendor/javascript") @importmap_path = Pathname.new(importmap_path) @vendor_path = Pathname.new(vendor_path) end def 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)) outdated_package.error = 'Response error' elsif (error = response['error']) outdated_package.error = error else latest_version = find_latest_version(response) next unless outdated?(current_version, latest_version) outdated_package.latest_version = latest_version end outdated_packages << outdated_package end.sort_by(&:name) end def vulnerable_packages get_audit.flat_map do |package, vulnerabilities| vulnerabilities.map do |vulnerability| VulnerablePackage.new( name: package, severity: vulnerability['severity'], vulnerable_versions: vulnerability['vulnerable_versions'], vulnerability: vulnerability['title'] ) end end.sort_by { |p| [p.name, p.severity] } end 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+(?:[^\/\s"']*))/) | importmap.scan(/#{PIN_REGEX} #.*@(\d+\.\d+\.\d+(?:[^\s]*)).*$/) with_versions.map! do |package, version| [extract_base_package_name(package), version] end.uniq! vendored_packages_without_version(with_versions).each do |package, path| $stdout.puts "Ignoring #{package} (#{path}) since no version is specified in the importmap" end with_versions end private OutdatedPackage = Struct.new(:name, :current_version, :latest_version, :error, keyword_init: true) VulnerablePackage = Struct.new(:name, :severity, :vulnerable_versions, :vulnerability, keyword_init: true) def importmap @importmap ||= File.read(@importmap_path) end def get_package(package) uri = self.class.base_uri.dup uri.path = "/" + package response = get_json(uri) JSON.parse(response) rescue JSON::ParserError nil end def get_json(uri) request = Net::HTTP::Get.new(uri) request["Content-Type"] = "application/json" response = begin Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) } rescue => error raise HTTPError, "Unexpected transport error (#{error.class}: #{error.message})" end unless response.code.to_i < 300 raise HTTPError, "Unexpected error response #{response.code}: #{response.body}" end response.body end def find_latest_version(response) latest_version = response.is_a?(String) ? response : response.dig('dist-tags', 'latest') return latest_version if latest_version return unless response['versions'] response['versions'].keys.map { |v| Gem::Version.new(v) rescue nil }.compact.sort.last end def outdated?(current_version, latest_version) Gem::Version.new(current_version) < Gem::Version.new(latest_version) rescue ArgumentError current_version.to_s < latest_version.to_s end def get_audit uri = self.class.base_uri.dup uri.path = "/-/npm/v1/security/advisories/bulk" body = packages_with_versions.each.with_object({}) { |(package, version), data| data[package] ||= [] data[package] << version } return {} if body.empty? response = post_json(uri, body) unless response.code.to_i < 300 raise HTTPError, "Unexpected error response #{response.code}: #{response.body}" end JSON.parse(response.body) end def post_json(uri, body) Net::HTTP.post(uri, body.to_json, "Content-Type" => "application/json") rescue => error raise HTTPError, "Unexpected transport error (#{error.class}: #{error.message})" end def extract_base_package_name(package) if package.start_with?("@") # Scoped packages can have nested paths, e.g. @scope/package/subpath parts = package.split("/", 3) parts.size > 2 ? parts.first(2).join("/") : package else # Non-scoped packages - just take the first part package.split("/").first end end def vendored_packages_without_version(packages_with_versions) versioned_packages = packages_with_versions.map(&:first).to_set importmap .lines .filter_map { |line| find_unversioned_vendored_package(line, versioned_packages) } end def find_unversioned_vendored_package(line, versioned_packages) regexp = line.include?("to:")? /#{PIN_REGEX}to: ["']([^"']*)["'].*/ : PIN_REGEX match = line.match(regexp) return unless match package, filename = match.captures filename ||= "#{package}.js" return if versioned_packages.include?(package) path = File.join(@vendor_path, filename) [package, path] if File.exist?(path) end end importmap-rails-2.2.3/lib/importmap/version.rb0000644000004100000410000000005115145701236021456 0ustar www-datawww-datamodule Importmap VERSION = "2.2.3" end importmap-rails-2.2.3/lib/importmap/map.rb0000644000004100000410000003001215145701236020546 0ustar www-datawww-datarequire "pathname" 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 @integrity = false @packages, @directories = {}, {} @cache = {} end def draw(path = nil, &block) if path && File.exist?(path) begin instance_eval(File.read(path), path.to_s) rescue StandardError => e Rails.logger.error "Unable to parse import map from #{path}: #{e.message}" raise InvalidFile, "Unable to parse import map from #{path}: #{e.message}" end elsif block_given? instance_eval(&block) end self end # Enables automatic integrity hash calculation for all pinned modules. # # When enabled, integrity values are included in the importmap JSON for all # pinned modules. For local assets served by the Rails asset pipeline, # integrity hashes are automatically calculated when +integrity: true+ is # specified. For modules with explicit integrity values, those values are # included as provided. This provides Subresource Integrity (SRI) protection # to ensure JavaScript modules haven't been tampered with. # # Clears the importmap cache when called to ensure fresh integrity hashes # are generated. # # ==== Examples # # # config/importmap.rb # enable_integrity! # # # These will now auto-calculate integrity hashes # pin "application" # integrity: true by default # pin "admin", to: "admin.js" # integrity: true by default # pin_all_from "app/javascript/lib" # integrity: true by default # # # Manual control still works # pin "no_integrity", integrity: false # pin "custom_hash", integrity: "sha384-abc123..." # # ==== Notes # # * Integrity calculation is disabled by default and must be explicitly enabled # * Requires asset pipeline support for integrity calculation (Sprockets or Propshaft 1.2+) # * For Propshaft, you must configure +config.assets.integrity_hash_algorithm+ # * External CDN packages should provide their own integrity hashes def enable_integrity! clear_cache @integrity = true end def pin(name, to: nil, preload: true, integrity: true) clear_cache @packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload, integrity: integrity) end def pin_all_from(dir, under: nil, to: nil, preload: true, integrity: true) clear_cache @directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload, integrity: integrity) end # Returns an array of all the resolved module paths of the pinned packages. The `resolver` must respond to # `path_to_asset`, such as `ActionController::Base.helpers` or `ApplicationController.helpers`. You'll want to use the # resolver that has been configured for the `asset_host` you want these resolved paths to use. In case you need to # resolve for different asset hosts, you can pass in a custom `cache_key` to vary the cache used by this method for # the different cases. def preloaded_module_paths(resolver:, entry_point: "application", cache_key: :preloaded_module_paths) preloaded_module_packages(resolver: resolver, entry_point: entry_point, cache_key: cache_key).keys end # Returns a hash of resolved module paths to their corresponding package objects for all pinned packages # that are marked for preloading. The hash keys are the resolved asset paths, and the values are the # +MappedFile+ objects containing package metadata including name, path, preload setting, and integrity. # # The +resolver+ must respond to +path_to_asset+, such as +ActionController::Base.helpers+ or # +ApplicationController.helpers+. You'll want to use the resolver that has been configured for the # +asset_host+ you want these resolved paths to use. # # ==== Parameters # # [+resolver+] # An object that responds to +path_to_asset+ for resolving asset paths. # # [+entry_point+] # The entry point name or array of entry point names to determine which packages should be preloaded. # Defaults to +"application"+. Packages with +preload: true+ are always included regardless of entry point. # Packages with specific entry point names (e.g., +preload: "admin"+) are only included when that entry # point is specified. # # [+cache_key+] # A custom cache key to vary the cache used by this method for different cases, such as resolving # for different asset hosts. Defaults to +:preloaded_module_packages+. # # ==== Returns # # A hash where: # * Keys are resolved asset paths (strings) # * Values are +MappedFile+ objects with +name+, +path+, +preload+, and +integrity+ attributes # # Missing assets are gracefully handled and excluded from the returned hash. # # ==== Examples # # # Get all preloaded packages for the default "application" entry point # packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers) # # => { "/assets/application-abc123.js" => #, # # "https://cdn.skypack.dev/react" => # } # # # Get preloaded packages for a specific entry point # packages = importmap.preloaded_module_packages(resolver: helpers, entry_point: "admin") # # # Get preloaded packages for multiple entry points # packages = importmap.preloaded_module_packages(resolver: helpers, entry_point: ["application", "admin"]) # # # Use a custom cache key for different asset hosts # packages = importmap.preloaded_module_packages(resolver: helpers, cache_key: "cdn_host") def preloaded_module_packages(resolver:, entry_point: "application", cache_key: :preloaded_module_packages) cache_as(cache_key) do expanded_preloading_packages_and_directories(entry_point:).filter_map do |_, package| resolved_path = resolve_asset_path(package.path, resolver: resolver) next unless resolved_path resolved_integrity = resolve_integrity_value(package.integrity, package.path, resolver: resolver) package = MappedFile.new( name: package.name, path: package.path, preload: package.preload, integrity: resolved_integrity ) [resolved_path, package] end.to_h end end # Returns a JSON hash (as a string) of all the resolved module paths of the pinned packages in the import map format. # The `resolver` must respond to `path_to_asset`, such as `ActionController::Base.helpers` or # `ApplicationController.helpers`. You'll want to use the resolver that has been configured for the `asset_host` you # want these resolved paths to use. In case you need to resolve for different asset hosts, you can pass in a custom # `cache_key` to vary the cache used by this method for the different cases. def to_json(resolver:, cache_key: :json) cache_as(cache_key) do packages = expanded_packages_and_directories map = build_import_map(packages, resolver: resolver) JSON.pretty_generate(map) end end # Returns a SHA1 digest of the import map json that can be used as a part of a page etag to # ensure that a html cache is invalidated when the import map is changed. # # Example: # # class ApplicationController < ActionController::Base # etag { Rails.application.importmap.digest(resolver: helpers) if request.format&.html? } # end def digest(resolver:) Digest::SHA1.hexdigest(to_json(resolver: resolver).to_s) end # Returns an instance of ActiveSupport::EventedFileUpdateChecker configured to clear the cache of the map # when the directories passed on initialization via `watches:` have changes. This is used in development # and test to ensure the map caches are reset when javascript files are changed. def cache_sweeper(watches: nil) if watches @cache_sweeper = Rails.application.config.file_watcher.new([], Array(watches).collect { |dir| [ dir.to_s, "js"] }.to_h) do clear_cache end else @cache_sweeper end end private MappedDir = Struct.new(:dir, :path, :under, :preload, :integrity, keyword_init: true) MappedFile = Struct.new(:name, :path, :preload, :integrity, keyword_init: true) def cache_as(name) if result = @cache[name.to_s] result else @cache[name.to_s] = yield end end def clear_cache @cache.clear end def rescuable_asset_error?(error) Rails.application.config.importmap.rescuable_asset_errors.any? { |e| error.is_a?(e) } end def resolve_asset_paths(paths, resolver:) paths.transform_values do |mapping| resolve_asset_path(mapping.path, resolver:) end.compact end def resolve_asset_path(path, resolver:) begin resolver.path_to_asset(path) rescue => e if rescuable_asset_error?(e) Rails.logger.warn "Importmap skipped missing path: #{path}" nil else raise e end end end def build_import_map(packages, resolver:) map = { "imports" => resolve_asset_paths(packages, resolver: resolver) } integrity = build_integrity_hash(packages, resolver: resolver) map["integrity"] = integrity unless integrity.empty? map end def build_integrity_hash(packages, resolver:) packages.filter_map do |name, mapping| next unless mapping.integrity resolved_path = resolve_asset_path(mapping.path, resolver: resolver) next unless resolved_path integrity_value = resolve_integrity_value(mapping.integrity, mapping.path, resolver: resolver) next unless integrity_value [resolved_path, integrity_value] end.to_h end def resolve_integrity_value(integrity, path, resolver:) return unless @integrity case integrity when true resolver.asset_integrity(path) if resolver.respond_to?(:asset_integrity) when String integrity end end def expanded_preloading_packages_and_directories(entry_point:) expanded_packages_and_directories.select { |name, mapping| mapping.preload.in?([true, false]) ? mapping.preload : (Array(mapping.preload) & Array(entry_point)).any? } end def expanded_packages_and_directories @packages.dup.tap { |expanded| expand_directories_into expanded } end def expand_directories_into(paths) @directories.values.each do |mapping| if (absolute_path = absolute_root_of(mapping.dir)).exist? find_javascript_files_in_tree(absolute_path).each do |filename| module_filename = filename.relative_path_from(absolute_path) module_name = module_name_from(module_filename, mapping) module_path = module_path_from(module_filename, mapping) paths[module_name] = MappedFile.new( name: module_name, path: module_path, preload: mapping.preload, integrity: mapping.integrity ) end end end end def module_name_from(filename, mapping) # Regex explanation: # (?:\/|^) # Matches either / OR the start of the string # index # Matches the word index # $ # Matches the end of the string # # Sample matches # index # folder/index index_regex = /(?:\/|^)index$/ [ mapping.under, filename.to_s.chomp(filename.extname).remove(index_regex).presence ].compact.join("/") end def module_path_from(filename, mapping) [ mapping.path || mapping.under, filename.to_s ].compact.reject(&:empty?).join("/") end def find_javascript_files_in_tree(path) Dir[path.join("**/*.js{,m}")].sort.collect { |file| Pathname.new(file) }.select(&:file?) end def absolute_root_of(path) (pathname = Pathname.new(path)).absolute? ? pathname : Rails.root.join(path) end end importmap-rails-2.2.3/lib/importmap/commands.rb0000644000004100000410000001361115145701236021600 0ustar www-datawww-datarequire "thor" require "importmap/packager" require "importmap/npm" class Importmap::Commands < Thor include Thor::Actions def self.exit_on_failure? false end desc "pin [*PACKAGES]", "Pin new packages" option :env, type: :string, aliases: :e, default: "production" option :from, type: :string, aliases: :f, default: "jspm" 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| pin_package(package, url, options[:preload]) end end desc "unpin [*PACKAGES]", "Unpin existing packages" option :env, type: :string, aliases: :e, default: "production" option :from, type: :string, aliases: :f, default: "jspm" def unpin(*packages) for_each_import(packages, env: options[:env], from: options[:from]) do |package, url| if packager.packaged?(package) puts %(Unpinning and removing "#{package}") packager.remove(package) end end end desc "pristine", "Redownload all pinned packages" option :env, type: :string, aliases: :e, default: "production" option :from, type: :string, aliases: :f, default: "jspm" def pristine packages = prepare_packages_with_versions for_each_import(packages, env: options[:env], from: options[:from]) do |package, url| puts %(Downloading "#{package}" to #{packager.vendor_path}/#{package}.js from #{url}) packager.download(package, url) end end desc "json", "Show the full importmap in json" def json require Rails.root.join("config/environment") puts Rails.application.importmap.to_json(resolver: ActionController::Base.helpers) end desc "audit", "Run a security audit" def audit vulnerable_packages = npm.vulnerable_packages if vulnerable_packages.any? table = [["Package", "Severity", "Vulnerable versions", "Vulnerability"]] vulnerable_packages.each { |p| table << [p.name, p.severity, p.vulnerable_versions, p.vulnerability] } puts_table(table) vulnerabilities = 'vulnerability'.pluralize(vulnerable_packages.size) severities = vulnerable_packages.map(&:severity).tally.sort_by(&:last).reverse .map { |severity, count| "#{count} #{severity}" } .join(", ") puts " #{vulnerable_packages.size} #{vulnerabilities} found: #{severities}" exit 1 else puts "No vulnerable packages found" end end desc "outdated", "Check for outdated packages" def outdated if (outdated_packages = npm.outdated_packages).any? table = [["Package", "Current", "Latest"]] outdated_packages.each { |p| table << [p.name, p.current_version, p.latest_version || p.error] } puts_table(table) packages = 'package'.pluralize(outdated_packages.size) puts " #{outdated_packages.size} outdated #{packages} found" exit 1 else puts "No outdated packages found" end end desc "update", "Update outdated package pins" def update if (outdated_packages = npm.outdated_packages).any? 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 end desc "packages", "Print out packages with version numbers" def packages puts npm.packages_with_versions.map { |x| x.join(' ') } end private def packager @packager ||= Importmap::Packager.new end 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", Importmap::Map.pin_line_regexp_for(package), pin, verbose: false) else append_to_file("config/importmap.rb", new_pin, verbose: false) end end def handle_package_not_found(packages, from) puts "Couldn't find any packages in #{packages.inspect} on #{from}" end def remove_line_from_file(path, pattern) path = File.expand_path(path, destination_root) all_lines = File.readlines(path) with_lines_removed = all_lines.select { |line| line !~ pattern } File.open(path, "w") do |file| with_lines_removed.each { |line| file.write(line) } end end def puts_table(array) column_sizes = array.reduce([]) do |lengths, row| row.each_with_index.map{ |iterand, index| [lengths[index] || 0, iterand.to_s.length].max } end divider = "|" + (column_sizes.map { |s| "-" * (s + 2) }.join('|')) + '|' array.each_with_index do |row, row_number| row = row.fill(nil, row.size..(column_sizes.size - 1)) row = row.each_with_index.map { |v, i| v.to_s + " " * (column_sizes[i] - v.to_s.length) } puts "| " + row.join(" | ") + " |" puts divider if row_number == 0 end end def prepare_packages_with_versions(packages = []) if packages.empty? npm.packages_with_versions.map do |p, v| v.blank? ? p : [p, v].join("@") end else packages end end def for_each_import(packages, **options, &block) response = packager.import(*packages, **options) if response response[:imports].each(&block) else handle_package_not_found(packages, options[:from]) end end end Importmap::Commands.start(ARGV) importmap-rails-2.2.3/lib/importmap/packager.rb0000644000004100000410000001312115145701236021550 0ustar www-datawww-datarequire "net/http" require "uri" 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) singleton_class.attr_accessor :endpoint self.endpoint = URI("https://api.jspm.io/generate") attr_reader :vendor_path def initialize(importmap_path = "config/importmap.rb", vendor_path: "vendor/javascript") @importmap_path = Pathname.new(importmap_path) @vendor_path = Pathname.new(vendor_path) end def import(*packages, env: "production", from: "jspm") response = post_json({ "install" => Array(packages), "flattenScope" => true, "env" => [ "browser", "module", env ], "provider" => normalize_provider(from), }) case response.code when "200" extract_parsed_response(response) when "404", "401" nil else handle_failure_response(response) end end def pin_for(package, url = nil, preloads: nil) to = url ? %(, to: "#{url}") : "" preload_param = preload(preloads) %(pin "#{package}") + to + preload_param end def vendored_pin_for(package, url, preloads = nil) filename = package_filename(package) version = extract_package_version_from(url) to = "#{package}.js" != filename ? filename : nil pin_for(package, to, preloads: preloads) + %( # #{version}) end def packaged?(package) importmap.match(Importmap::Map.pin_line_regexp_for(package)) end def download(package, url) ensure_vendor_directory_exists remove_existing_package_file(package) download_package_file(package, url) end def remove(package) remove_existing_package_file(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"] | [true] %(, preload: true) in ["false"] | [false] %(, preload: false) in [string] %(, preload: "#{string}") else %(, preload: #{preloads}) end end def post_json(body) Net::HTTP.post(self.class.endpoint, body.to_json, "Content-Type" => "application/json") rescue => error raise HTTPError, "Unexpected transport error (#{error.class}: #{error.message})" end def normalize_provider(name) name.to_s == "jspm" ? "jspm.io" : name.to_s end def extract_parsed_response(response) parsed = JSON.parse(response.body) imports = parsed.dig("map", "imports") { imports: imports, } end def handle_failure_response(response) if error_message = parse_service_error(response) raise ServiceError, error_message else raise HTTPError, "Unexpected response code (#{response.code})" end end def parse_service_error(response) JSON.parse(response.body.to_s)["error"] rescue JSON::ParserError nil end def importmap @importmap ||= File.read(@importmap_path) end def ensure_vendor_directory_exists FileUtils.mkdir_p @vendor_path end def remove_existing_package_file(package) FileUtils.rm_rf vendored_package_path(package) end def remove_package_from_importmap(package) all_lines = File.readlines(@importmap_path) 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) } end end def download_package_file(package, url) response = Net::HTTP.get_response(URI(url)) if response.code == "200" save_vendored_package(package, url, response.body) else handle_failure_response(response) end end def save_vendored_package(package, url, source) File.open(vendored_package_path(package), "w+") do |vendored_package| vendored_package.write "// #{package}#{extract_package_version_from(url)} downloaded from #{url}\n\n" vendored_package.write remove_sourcemap_comment_from(source).force_encoding("UTF-8") end end def remove_sourcemap_comment_from(source) source.gsub(/^\/\/# sourceMappingURL=.*/, "") end def vendored_package_path(package) @vendor_path.join(package_filename(package)) end def package_filename(package) package.gsub("/", "--") + ".js" end def extract_package_version_from(url) url.match(/@\d+\.\d+\.\d+/)&.to_a&.first end end importmap-rails-2.2.3/lib/importmap-rails.rb0000644000004100000410000000020615145701236021103 0ustar www-datawww-datamodule Importmap end require "importmap/version" require "importmap/reloader" require "importmap/engine" if defined?(Rails::Railtie) importmap-rails-2.2.3/Rakefile0000644000004100000410000000050615145701236016340 0ustar www-datawww-datarequire "bundler/setup" APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__) load "rails/tasks/engine.rake" require "bundler/gem_tasks" require "rake/testtask" Rake::TestTask.new(:test) do |t| t.libs << 'test' t.pattern = 'test/**/*_test.rb' t.verbose = false t.warning = false end task default: :test importmap-rails-2.2.3/MIT-LICENSE0000644000004100000410000000203415145701236016325 0ustar www-datawww-dataCopyright (c) 2022 Basecamp Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. importmap-rails-2.2.3/app/0000755000004100000410000000000015145701236015452 5ustar www-datawww-dataimportmap-rails-2.2.3/app/helpers/0000755000004100000410000000000015145701236017114 5ustar www-datawww-dataimportmap-rails-2.2.3/app/helpers/importmap/0000755000004100000410000000000015145701236021124 5ustar www-datawww-dataimportmap-rails-2.2.3/app/helpers/importmap/importmap_tags_helper.rb0000644000004100000410000000444215145701236026042 0ustar www-datawww-datamodule Importmap::ImportmapTagsHelper # Setup all script tags needed to use an importmap-powered entrypoint (which defaults to application.js) def javascript_importmap_tags(entry_point = "application", importmap: Rails.application.importmap) safe_join [ javascript_inline_importmap_tag(importmap.to_json(resolver: self)), javascript_importmap_module_preload_tags(importmap, entry_point:), javascript_import_module_tag(entry_point) ], "\n" end # Generate an inline importmap tag using the passed `importmap_json` JSON string. # By default, `Rails.application.importmap.to_json(resolver: self)` is used. def javascript_inline_importmap_tag(importmap_json = Rails.application.importmap.to_json(resolver: self)) tag.script importmap_json.html_safe, type: "importmap", "data-turbo-track": "reload", nonce: request&.content_security_policy_nonce end # Import a named JavaScript module(s) using a script-module tag. def javascript_import_module_tag(*module_names) imports = Array(module_names).collect { |m| %(import "#{m}") }.join("\n") tag.script imports.html_safe, type: "module", nonce: request&.content_security_policy_nonce end # Link tags for preloading all modules marked as preload: true in the `importmap` # (defaults to Rails.application.importmap), such that they'll be fetched # in advance by browsers supporting this link type (https://caniuse.com/?search=modulepreload). def javascript_importmap_module_preload_tags(importmap = Rails.application.importmap, entry_point: "application") packages = importmap.preloaded_module_packages(resolver: self, entry_point:, cache_key: entry_point) _generate_preload_tags(packages) { |path, package| [path, { integrity: package.integrity }] } end # Link tag(s) for preloading the JavaScript module residing in `*paths`. Will return one link tag per path element. def javascript_module_preload_tag(*paths) _generate_preload_tags(paths) { |path| [path, {}] } end private def _generate_preload_tags(items) content_security_policy_nonce = request&.content_security_policy_nonce safe_join(Array(items).collect { |item| path, options = yield(item) tag.link rel: "modulepreload", href: path, nonce: content_security_policy_nonce, **options }, "\n") end end importmap-rails-2.2.3/app/controllers/0000755000004100000410000000000015145701236020020 5ustar www-datawww-dataimportmap-rails-2.2.3/app/controllers/importmap/0000755000004100000410000000000015145701236022030 5ustar www-datawww-dataimportmap-rails-2.2.3/app/controllers/importmap/freshness.rb0000644000004100000410000000024515145701236024356 0ustar www-datawww-datamodule Importmap::Freshness def stale_when_importmap_changes etag { Rails.application.importmap.digest(resolver: helpers) if request.format&.html? } end end importmap-rails-2.2.3/README.md0000644000004100000410000004031215145701236016151 0ustar www-datawww-data# Importmap for Rails [Import maps](https://github.com/WICG/import-maps) let you import JavaScript modules using logical names that map to versioned/digested files – directly from the browser. So you can [build modern JavaScript applications using JavaScript libraries made for ES modules (ESM) without the need for transpiling or bundling](https://world.hey.com/dhh/modern-web-apps-without-javascript-bundling-or-transpiling-a20f2755). This frees you from needing Webpack, Yarn, npm, or any other part of the JavaScript toolchain. All you need is the asset pipeline that's already included in Rails. With this approach you'll ship many small JavaScript files instead of one big JavaScript file. Thanks to HTTP/2 that no longer carries a material performance penalty during the initial transport, and in fact offers substantial benefits over the long run due to better caching dynamics. Whereas before any change to any JavaScript file included in your big bundle would invalidate the cache for the whole bundle, now only the cache for that single file is invalidated. [Import maps are supported natively in all major, modern browsers](https://caniuse.com/?search=importmap). If you need to work with legacy browsers without native support, you can explore using [the shim available](https://github.com/guybedford/es-module-shims). ## Installation Importmap for Rails is automatically included in Rails 7+ for new applications, but you can also install it manually in existing applications: 1. Run `./bin/bundle add importmap-rails` 2. Run `./bin/rails importmap:install` Note: In order to use JavaScript from Rails frameworks like Action Cable, Action Text, and Active Storage, you must be running Rails 7.0+. This was the first version that shipped with ESM compatible builds of these libraries. You can pin those libraries manually by relying on the compiled versions included in Rails like this: ```ruby pin "@rails/actioncable", to: "actioncable.esm.js" pin "@rails/activestorage", to: "activestorage.esm.js" pin "@rails/actiontext", to: "actiontext.esm.js" pin "trix" ``` ## How do importmaps work? At their core, importmaps are essentially a string substitution for what are referred to as "bare module specifiers". A "bare module specifier" looks like this: `import React from "react"`. This is not compatible with the ES Module loader spec. Instead, to be ESM compatible, you must provide 1 of the 3 following types of specifiers: - Absolute path: ```js import React from "/Users/DHH/projects/basecamp/node_modules/react" ``` - Relative path: ```js import React from "./node_modules/react" ``` - HTTP path: ```js import React from "https://ga.jspm.io/npm:react@17.0.1/index.js" ``` Importmap-rails provides a clean API for mapping "bare module specifiers" like `"react"` to 1 of the 3 viable ways of loading ES Module javascript packages. For example: ```rb # config/importmap.rb pin "react", to: "https://ga.jspm.io/npm:react@17.0.2/index.js" ``` means "every time you see `import React from "react"` change it to `import React from "https://ga.jspm.io/npm:react@17.0.2/index.js"`" ```js import React from "react" // => import React from "https://ga.jspm.io/npm:react@17.0.2/index.js" ``` ## Usage The import map is setup through `Rails.application.importmap` via the configuration in `config/importmap.rb`. This file is automatically reloaded in development upon changes, but note that you must restart the server if you remove pins and need them gone from the rendered importmap or list of preloads. This import map is inlined in the `` of your application layout using `<%= javascript_importmap_tags %>`, which will setup the JSON configuration inside a ``. That logical entrypoint, `application`, is mapped in the importmap script tag to the file `app/javascript/application.js`. It's in `app/javascript/application.js` you setup your application by importing any of the modules that have been defined in the import map. You can use the full ESM functionality of importing any particular export of the modules or everything. It makes sense to use logical names that match the package names used by npm, such that if you later want to start transpiling or bundling your code, you won't have to change any module imports. ### Local modules If you want to import local js module files from `app/javascript/src` or other sub-folders of `app/javascript` (such as `channels`), you must pin these to be able to import them. You can use `pin_all_from` to pick all files in a specific folder, so you don't have to `pin` each module individually. ```rb # config/importmap.rb pin_all_from 'app/javascript/src', under: 'src', to: 'src' # With automatic integrity calculation for enhanced security enable_integrity! pin_all_from 'app/javascript/controllers', under: 'controllers', integrity: true ``` The `:to` parameter is only required if you want to change the destination logical import name. If you drop the :to option, you must place the :under option directly after the first parameter. The `enable_integrity!` call enables integrity calculation globally, and `integrity: true` automatically calculates integrity hashes for all files in the directory, providing security benefits without manual hash management. Allows you to: ```js // app/javascript/application.js import { ExampleFunction } from 'src/example_function' ``` Which imports the function from `app/javascript/src/example_function.js`. Note: Sprockets used to serve assets (albeit without filename digests) it couldn't find from the `app/javascripts` folder with logical relative paths, meaning pinning local files wasn't needed. Propshaft doesn't have this fallback, so when you use Propshaft you have to pin your local modules. ## Using npm packages via JavaScript CDNs Importmap for Rails downloads and vendors your npm package dependencies via JavaScript CDNs that provide pre-compiled distribution versions. You can use the `./bin/importmap` command that's added as part of the install to pin, unpin, or update npm packages in your import map. By default this command uses an API from [JSPM.org](https://jspm.org) to resolve your package dependencies efficiently, and then add the pins to your `config/importmap.rb` file. ```bash ./bin/importmap pin react Pinning "react" to vendor/javascript/react.js via download from https://ga.jspm.io/npm:react@19.1.0/index.js ``` This will produce a pin in your `config/importmap.rb` like so: ```ruby pin "react" # @19.1.0 ``` Other CDNs like [unpkg.com](https://unpkg.com) and [jsdelivr.com](https://www.jsdelivr.com) can be specified with `--from`: ```bash ./bin/importmap pin react --from unpkg Pinning "react" to vendor/javascript/react.js via download from https://unpkg.com/react@19.1.0/index.js ``` ```bash ./bin/importmap pin react --from jsdelivr Pinning "react" to vendor/javascript/react.js via download from https://cdn.jsdelivr.net/npm/react@19.1.0/index.js ``` The packages are downloaded to `vendor/javascript`, which you can check into your source control, and they'll be available through your application's own asset pipeline serving. If you later wish to remove a downloaded pin: ```bash ./bin/importmap unpin react Unpinning and removing "react" ``` ## Subresource Integrity (SRI) For enhanced security, importmap-rails supports [Subresource Integrity (SRI)](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) hashes for packages loaded from external CDNs. ### Automatic integrity for local assets To enable automatic integrity calculation for local assets served by the Rails asset pipeline, you must first call `enable_integrity!` in your importmap configuration: ```ruby # config/importmap.rb # Enable integrity calculation globally enable_integrity! # With integrity enabled, these will auto-calculate integrity hashes pin "application" # Auto-calculated integrity pin "admin", to: "admin.js" # Auto-calculated integrity pin_all_from "app/javascript/controllers", under: "controllers" # Auto-calculated integrity # Mixed usage - explicitly controlling integrity pin "cdn_package", integrity: "sha384-abc123..." # Pre-calculated hash pin "no_integrity_package", integrity: false # Explicitly disable integrity pin "nil_integrity_package", integrity: nil # Explicitly disable integrity ``` This is particularly useful for: * **Local JavaScript files** managed by your Rails asset pipeline * **Bulk operations** with `pin_all_from` where calculating hashes manually would be tedious * **Development workflow** where asset contents change frequently **Note:** Integrity calculation is opt-in and must be enabled with `enable_integrity!`. This behavior can be further controlled by setting `integrity: false` or `integrity: nil` on individual pins. **Important for Propshaft users:** SRI support requires Propshaft 1.2+ and you must configure the integrity hash algorithm in your application: ```ruby # config/application.rb or config/environments/*.rb config.assets.integrity_hash_algorithm = 'sha256' # or 'sha384', 'sha512' ``` Without this configuration, integrity will be disabled by default when using Propshaft. Sprockets includes integrity support out of the box. **Example output with `enable_integrity!` and `integrity: true`:** ```json { "imports": { "application": "/assets/application-abc123.js", "controllers/hello_controller": "/assets/controllers/hello_controller-def456.js" }, "integrity": { "/assets/application-abc123.js": "sha256-xyz789...", "/assets/controllers/hello_controller-def456.js": "sha256-uvw012..." } } ``` ### How integrity works The integrity hashes are automatically included in your import map and module preload tags: **Import map JSON:** ```json { "imports": { "lodash": "https://ga.jspm.io/npm:lodash@4.17.21/lodash.js", "application": "/assets/application-abc123.js", "controllers/hello_controller": "/assets/controllers/hello_controller-def456.js" }, "integrity": { "https://ga.jspm.io/npm:lodash@4.17.21/lodash.js": "sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF" "/assets/application-abc123.js": "sha256-xyz789...", "/assets/controllers/hello_controller-def456.js": "sha256-uvw012..." } } ``` **Module preload tags:** ```html ``` Modern browsers will automatically validate these integrity hashes when loading the JavaScript modules, ensuring the files haven't been modified. ## Preloading pinned modules To avoid the waterfall effect where the browser has to load one file after another before it can get to the deepest nested import, importmap-rails uses [modulepreload links](https://developers.google.com/web/updates/2017/12/modulepreload) by default. If you don't want to preload a dependency, because you want to load it on-demand for efficiency, append `preload: false` to the pin. Example: ```ruby # config/importmap.rb pin "@github/hotkey", to: "@github--hotkey.js" # file lives in vendor/javascript/@github--hotkey.js pin "md5", preload: false # file lives in vendor/javascript/md5.js # app/views/layouts/application.html.erb <%= javascript_importmap_tags %> # will include the following link before the importmap is setup: ... ``` You can also specify which entry points to preload a particular dependency in by providing `preload:` a string or array of strings. Example: ```ruby # config/importmap.rb pin "@github/hotkey", to: "@github--hotkey.js", preload: 'application' pin "md5", preload: ['application', 'alternate'] # app/views/layouts/application.html.erb <%= javascript_importmap_tags 'alternate' %> # will include the following link before the importmap is setup: ... ``` ## Composing import maps By default, Rails loads import map definition from the application's `config/importmap.rb` to the `Importmap::Map` object available at `Rails.application.importmap`. You can combine multiple import maps by adding paths to additional import map configs to `Rails.application.config.importmap.paths`. For example, appending import maps defined in Rails engines: ```ruby # my_engine/lib/my_engine/engine.rb module MyEngine class Engine < ::Rails::Engine # ... initializer "my-engine.importmap", before: "importmap" do |app| app.config.importmap.paths << Engine.root.join("config/importmap.rb") # ... end end end ``` And pinning JavaScript modules from the engine: ```ruby # my_engine/config/importmap.rb pin_all_from File.expand_path("../app/assets/javascripts", __dir__) ``` ## Selectively importing modules You can selectively import your javascript modules on specific pages. Create your javascript in `app/javascript`: ```js // /app/javascript/checkout.js // some checkout specific js ``` Pin your js file: ```rb # config/importmap.rb # ... other pins... pin "checkout", preload: false ``` Import your module on the specific page. Note: you'll likely want to use a `content_for` block on the specific page/partial, then yield it in your layout. ```erb <% content_for :head do %> <%= javascript_import_module_tag "checkout" %> <% end %> ``` **Important**: The `javascript_import_module_tag` should come after your `javascript_importmap_tags` ```erb <%= javascript_importmap_tags %> <%= yield(:head) %> ``` ## Include a digest of the import map in your ETag If you're using [ETags](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) generated by Rails helpers like `stale?` or `fresh_when`, you need to include the digest of the import map into this calculation. Otherwise your application will return [304](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304) cache responses even when your JavaScript assets have changed. You can avoid this using the `stale_when_importmap_changes` method: ```ruby class ApplicationController < ActionController::Base stale_when_importmap_changes end ``` This will add the digest of the importmap to the etag calculation when the request format is HTML. ## Sweeping the cache in development and test Generating the import map json and modulepreloads may require resolving hundreds of assets. This can take a while, so these operations are cached, but in development and test, we watch for changes to both `config/importmap.rb` and files in `app/javascript` to clear this cache. This feature can be controlled in an environment configuration file via the boolean `config.importmap.sweep_cache`. If you're pinning local files from outside of `app/javascript`, you'll need to add them to the cache sweeper configuration or restart your development server upon changes to those external files. For example, here's how you can do it for Rails engine: ```ruby # my_engine/lib/my_engine/engine.rb module MyEngine class Engine < ::Rails::Engine # ... initializer "my-engine.importmap", before: "importmap" do |app| # ... app.config.importmap.cache_sweepers << Engine.root.join("app/assets/javascripts") end end end ``` ## Checking for outdated or vulnerable packages Importmap for Rails provides two commands to check your pinned packages: - `./bin/importmap outdated` checks the NPM registry for new versions - `./bin/importmap audit` checks the NPM registry for known security issues ## Supporting legacy browsers such as Safari on iOS 15 If you want to support [legacy browsers that do not support import maps](https://caniuse.com/import-maps) such as [iOS 15.8.1 released on 22 Jan 2024](https://support.apple.com/en-us/HT201222), insert [`es-module-shims`](https://github.com/guybedford/es-module-shims) before `javascript_importmap_tags` as below. ```erb <%= javascript_importmap_tags %> ``` ## License Importmap for Rails is released under the [MIT License](https://opensource.org/licenses/MIT).