importmap 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ class Importmap::Framework
2
+ module RailsFramework
3
+ def framework_class
4
+ Rails
5
+ end
6
+
7
+ def boot
8
+ require "./config/environment"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,49 @@
1
+ require "active_support/core_ext/string/inflections"
2
+
3
+ module Importmap
4
+ class Framework
5
+
6
+ @@instance = nil
7
+ def self.instance
8
+ @@instance ||= new
9
+ end
10
+
11
+ def initialize
12
+ require framework_name
13
+ Importmap::Framework.send(:include, include_framework)
14
+ end
15
+
16
+ def framework_name
17
+ ENV['IMPORTMAP_FRAMEWORK'] || infer_from_gemfile || "jets"
18
+ end
19
+
20
+ # define at bottom so methods take precedence
21
+ def include_framework
22
+ if supported_frameworks.include?(framework_name)
23
+ "Importmap::Framework::#{framework_name.camelize}Framework".constantize
24
+ else
25
+ raise "Need to use a supported framework: #{supported_frameworks.join(', ')}"
26
+ end
27
+ end
28
+
29
+ def infer_from_gemfile
30
+ return unless File.exist?("Gemfile")
31
+ lines = IO.readlines("Gemfile")
32
+ lines.each do |line|
33
+ next if line =~ /^\s*#/ # skip comments
34
+ gem_name = line.match(/gem ['"](\w+)['"]/)&.captures&.first
35
+ if supported_frameworks.include?(gem_name)
36
+ return gem_name
37
+ end
38
+ end
39
+ nil
40
+ end
41
+
42
+ # To add another framework:
43
+ # 1. add to this list
44
+ # 2. define a module in importmap/framework/#{framework_name}_framework.rb
45
+ def supported_frameworks
46
+ %w[jets rails]
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,158 @@
1
+ require "pathname"
2
+
3
+ class Importmap::Map
4
+ attr_reader :packages, :directories
5
+
6
+ class InvalidFile < StandardError; end
7
+
8
+ def initialize
9
+ @packages, @directories = {}, {}
10
+ @cache = {}
11
+ end
12
+
13
+ def draw(path = nil, &block)
14
+ if path && File.exist?(path)
15
+ begin
16
+ instance_eval(File.read(path), path.to_s)
17
+ rescue StandardError => e
18
+ Importmap.framework.logger.error "Unable to parse import map from #{path}: #{e.message}"
19
+ raise InvalidFile, "Unable to parse import map from #{path}: #{e.message}"
20
+ end
21
+ elsif block_given?
22
+ instance_eval(&block)
23
+ end
24
+
25
+ self
26
+ end
27
+
28
+ def pin(name, to: nil, preload: false)
29
+ clear_cache
30
+ @packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload)
31
+ end
32
+
33
+ def pin_all_from(dir, under: nil, to: nil, preload: false)
34
+ clear_cache
35
+ @directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload)
36
+ end
37
+
38
+ # Returns an array of all the resolved module paths of the pinned packages. The `resolver` must respond to
39
+ # `path_to_asset`, such as `ActionController::Base.helpers` or `ApplicationController.helpers`. You'll want to use the
40
+ # resolver that has been configured for the `asset_host` you want these resolved paths to use. In case you need to
41
+ # resolve for different asset hosts, you can pass in a custom `cache_key` to vary the cache used by this method for
42
+ # the different cases.
43
+ def preloaded_module_paths(resolver:, cache_key: :preloaded_module_paths)
44
+ cache_as(cache_key) do
45
+ resolve_asset_paths(expanded_preloading_packages_and_directories, resolver: resolver).values
46
+ end
47
+ end
48
+
49
+ # Returns a JSON hash (as a string) of all the resolved module paths of the pinned packages in the import map format.
50
+ # The `resolver` must respond to `path_to_asset`, such as `ActionController::Base.helpers` or
51
+ # `ApplicationController.helpers`. You'll want to use the resolver that has been configured for the `asset_host` you
52
+ # want these resolved paths to use. In case you need to resolve for different asset hosts, you can pass in a custom
53
+ # `cache_key` to vary the cache used by this method for the different cases.
54
+ def to_json(resolver:, cache_key: :json)
55
+ cache_as(cache_key) do
56
+ JSON.pretty_generate({ "imports" => resolve_asset_paths(expanded_packages_and_directories, resolver: resolver) })
57
+ end
58
+ end
59
+
60
+ # Returns a SHA1 digest of the import map json that can be used as a part of a page etag to
61
+ # ensure that a html cache is invalidated when the import map is changed.
62
+ #
63
+ # Example:
64
+ #
65
+ # class ApplicationController < ActionController::Base
66
+ # etag { Importmap.framework.application.importmap.digest(resolver: helpers) if request.format&.html? }
67
+ # end
68
+ def digest(resolver:)
69
+ Digest::SHA1.hexdigest(to_json(resolver: resolver).to_s)
70
+ end
71
+
72
+ # Returns an instance ActiveSupport::EventedFileUpdateChecker configured to clear the cache of the map
73
+ # when the directories passed on initialization via `watches:` have changes. This is used in development
74
+ # and test to ensure the map caches are reset when javascript files are changed.
75
+ def cache_sweeper(watches: nil)
76
+ if watches
77
+ @cache_sweeper =
78
+ Importmap.framework.application.config.file_watcher.new([], Array(watches).collect { |dir| [ dir.to_s, "js"] }.to_h) do
79
+ clear_cache
80
+ end
81
+ else
82
+ @cache_sweeper
83
+ end
84
+ end
85
+
86
+ private
87
+ MappedDir = Struct.new(:dir, :path, :under, :preload, keyword_init: true)
88
+ MappedFile = Struct.new(:name, :path, :preload, keyword_init: true)
89
+
90
+ def cache_as(name)
91
+ if result = @cache[name.to_s]
92
+ result
93
+ else
94
+ @cache[name.to_s] = yield
95
+ end
96
+ end
97
+
98
+ def clear_cache
99
+ @cache.clear
100
+ end
101
+
102
+ def rescuable_asset_error?(error)
103
+ Importmap.framework.application.config.importmap.rescuable_asset_errors.any? { |e| error.is_a?(e) }
104
+ end
105
+
106
+ def resolve_asset_paths(paths, resolver:)
107
+ paths.transform_values do |mapping|
108
+ begin
109
+ resolver.path_to_asset(mapping.path)
110
+ rescue => e
111
+ if rescuable_asset_error?(e)
112
+ Importmap.framework.logger.warn "Importmap skipped missing path: #{mapping.path}"
113
+ nil
114
+ else
115
+ raise e
116
+ end
117
+ end
118
+ end.compact
119
+ end
120
+
121
+ def expanded_preloading_packages_and_directories
122
+ expanded_packages_and_directories.select { |name, mapping| mapping.preload }
123
+ end
124
+
125
+ def expanded_packages_and_directories
126
+ @packages.dup.tap { |expanded| expand_directories_into expanded }
127
+ end
128
+
129
+ def expand_directories_into(paths)
130
+ @directories.values.each do |mapping|
131
+ if (absolute_path = absolute_root_of(mapping.dir)).exist?
132
+ find_javascript_files_in_tree(absolute_path).each do |filename|
133
+ module_filename = filename.relative_path_from(absolute_path)
134
+ module_name = module_name_from(module_filename, mapping)
135
+ module_path = module_path_from(module_filename, mapping)
136
+
137
+ paths[module_name] = MappedFile.new(name: module_name, path: module_path, preload: mapping.preload)
138
+ end
139
+ end
140
+ end
141
+ end
142
+
143
+ def module_name_from(filename, mapping)
144
+ [ mapping.under, filename.to_s.remove(filename.extname).remove(/\/?index$/).presence ].compact.join("/")
145
+ end
146
+
147
+ def module_path_from(filename, mapping)
148
+ [ mapping.path || mapping.under, filename.to_s ].compact.join("/")
149
+ end
150
+
151
+ def find_javascript_files_in_tree(path)
152
+ Dir[path.join("**/*.js{,m}")].collect { |file| Pathname.new(file) }.select(&:file?)
153
+ end
154
+
155
+ def absolute_root_of(path)
156
+ (pathname = Pathname.new(path)).absolute? ? pathname : Importmap.framework.root.join(path)
157
+ end
158
+ end
@@ -0,0 +1,124 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+
5
+ module Importmap
6
+ class Npm
7
+ Error = Class.new(StandardError)
8
+ HTTPError = Class.new(Error)
9
+
10
+ singleton_class.attr_accessor :base_uri
11
+ self.base_uri = URI("https://registry.npmjs.org")
12
+
13
+ def initialize(importmap_path = "config/importmap.rb")
14
+ @importmap_path = Pathname.new(importmap_path)
15
+ end
16
+
17
+ def outdated_packages
18
+ packages_with_versions.each.with_object([]) do |(package, current_version), outdated_packages|
19
+ outdated_package = OutdatedPackage.new(name: package,
20
+ current_version: current_version)
21
+
22
+ if !(response = get_package(package))
23
+ outdated_package.error = 'Response error'
24
+ elsif (error = response['error'])
25
+ outdated_package.error = error
26
+ else
27
+ latest_version = find_latest_version(response)
28
+ next unless outdated?(current_version, latest_version)
29
+
30
+ outdated_package.latest_version = latest_version
31
+ end
32
+
33
+ outdated_packages << outdated_package
34
+ end.sort_by(&:name)
35
+ end
36
+
37
+ def vulnerable_packages
38
+ get_audit.flat_map do |package, vulnerabilities|
39
+ vulnerabilities.map do |vulnerability|
40
+ VulnerablePackage.new(name: package,
41
+ severity: vulnerability['severity'],
42
+ vulnerable_versions: vulnerability['vulnerable_versions'],
43
+ vulnerability: vulnerability['title'])
44
+ end
45
+ end.sort_by { |p| [p.name, p.severity] }
46
+ end
47
+
48
+ def packages_with_versions
49
+ # We cannot use the name after "pin" because some dependencies are loaded from inside packages
50
+ # Eg. pin "buffer", to: "https://ga.jspm.io/npm:@jspm/core@2.0.0-beta.19/nodelibs/browser/buffer.js"
51
+
52
+ importmap.scan(/^pin .*(?<=npm:|npm\/|skypack\.dev\/|unpkg\.com\/)(.*)(?=@\d+\.\d+\.\d+)@(\d+\.\d+\.\d+(?:[^\/\s"]*)).*$/) |
53
+ importmap.scan(/^pin "([^"]*)".* #.*@(\d+\.\d+\.\d+(?:[^\s]*)).*$/)
54
+ end
55
+
56
+ private
57
+ OutdatedPackage = Struct.new(:name, :current_version, :latest_version, :error, keyword_init: true)
58
+ VulnerablePackage = Struct.new(:name, :severity, :vulnerable_versions, :vulnerability, keyword_init: true)
59
+
60
+
61
+
62
+ def importmap
63
+ @importmap ||= File.read(@importmap_path)
64
+ end
65
+
66
+ def get_package(package)
67
+ uri = self.class.base_uri.dup
68
+ uri.path = "/" + package
69
+ response = get_json(uri)
70
+
71
+ JSON.parse(response)
72
+ rescue JSON::ParserError
73
+ nil
74
+ end
75
+
76
+ def get_json(uri)
77
+ request = Net::HTTP::Get.new(uri)
78
+ request["Content-Type"] = "application/json"
79
+
80
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http|
81
+ http.request(request)
82
+ }
83
+
84
+ response.body
85
+ rescue => error
86
+ raise HTTPError, "Unexpected transport error (#{error.class}: #{error.message})"
87
+ end
88
+
89
+ def find_latest_version(response)
90
+ latest_version = response.dig('dist-tags', 'latest')
91
+ return latest_version if latest_version
92
+
93
+ return unless response['versions']
94
+
95
+ response['versions'].keys.map { |v| Gem::Version.new(v) rescue nil }.compact.sort.last
96
+ end
97
+
98
+ def outdated?(current_version, latest_version)
99
+ Gem::Version.new(current_version) < Gem::Version.new(latest_version)
100
+ rescue ArgumentError
101
+ current_version.to_s < latest_version.to_s
102
+ end
103
+
104
+ def get_audit
105
+ uri = self.class.base_uri.dup
106
+ uri.path = "/-/npm/v1/security/advisories/bulk"
107
+
108
+ body = packages_with_versions.each.with_object({}) { |(package, version), data|
109
+ data[package] ||= []
110
+ data[package] << version
111
+ }
112
+ return {} if body.empty?
113
+
114
+ response = post_json(uri, body)
115
+ JSON.parse(response.body)
116
+ end
117
+
118
+ def post_json(uri, body)
119
+ Net::HTTP.post(uri, body.to_json, "Content-Type" => "application/json")
120
+ rescue => error
121
+ raise HTTPError, "Unexpected transport error (#{error.class}: #{error.message})"
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,145 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+
5
+ module Importmap
6
+ class Packager
7
+ Error = Class.new(StandardError)
8
+ HTTPError = Class.new(Error)
9
+ ServiceError = Error.new(Error)
10
+
11
+ singleton_class.attr_accessor :endpoint
12
+ self.endpoint = URI("https://api.jspm.io/generate")
13
+
14
+ attr_reader :vendor_path
15
+
16
+ def initialize(importmap_path = "config/importmap.rb", vendor_path: "vendor/javascript")
17
+ @importmap_path = Pathname.new(importmap_path)
18
+ @vendor_path = Pathname.new(vendor_path)
19
+ end
20
+
21
+ def import(*packages, env: "production", from: "jspm")
22
+ response = post_json({
23
+ "install" => Array(packages),
24
+ "flattenScope" => true,
25
+ "env" => [ "browser", "module", env ],
26
+ "provider" => from.to_s,
27
+ })
28
+
29
+ case response.code
30
+ when "200" then extract_parsed_imports(response)
31
+ when "404", "401" then nil
32
+ else handle_failure_response(response)
33
+ end
34
+ end
35
+
36
+ def pin_for(package, url)
37
+ %(pin "#{package}", to: "#{url}")
38
+ end
39
+
40
+ def vendored_pin_for(package, url)
41
+ filename = package_filename(package)
42
+ version = extract_package_version_from(url)
43
+
44
+ if "#{package}.js" == filename
45
+ %(pin "#{package}" # #{version})
46
+ else
47
+ %(pin "#{package}", to: "#{filename}" # #{version})
48
+ end
49
+ end
50
+
51
+ def packaged?(package)
52
+ importmap.match(/^pin ["']#{package}["'].*$/)
53
+ end
54
+
55
+ def download(package, url)
56
+ ensure_vendor_directory_exists
57
+ remove_existing_package_file(package)
58
+ download_package_file(package, url)
59
+ end
60
+
61
+ def remove(package)
62
+ remove_existing_package_file(package)
63
+ remove_package_from_importmap(package)
64
+ end
65
+
66
+ private
67
+ def post_json(body)
68
+ Net::HTTP.post(self.class.endpoint, body.to_json, "Content-Type" => "application/json")
69
+ rescue => error
70
+ raise HTTPError, "Unexpected transport error (#{error.class}: #{error.message})"
71
+ end
72
+
73
+ def extract_parsed_imports(response)
74
+ JSON.parse(response.body).dig("map", "imports")
75
+ end
76
+
77
+ def handle_failure_response(response)
78
+ if error_message = parse_service_error(response)
79
+ raise ServiceError, error_message
80
+ else
81
+ raise HTTPError, "Unexpected response code (#{response.code})"
82
+ end
83
+ end
84
+
85
+ def parse_service_error(response)
86
+ JSON.parse(response.body.to_s)["error"]
87
+ rescue JSON::ParserError
88
+ nil
89
+ end
90
+
91
+ def importmap
92
+ @importmap ||= File.read(@importmap_path)
93
+ end
94
+
95
+
96
+ def ensure_vendor_directory_exists
97
+ FileUtils.mkdir_p @vendor_path
98
+ end
99
+
100
+ def remove_existing_package_file(package)
101
+ FileUtils.rm_rf vendored_package_path(package)
102
+ end
103
+
104
+ def remove_package_from_importmap(package)
105
+ all_lines = File.readlines(@importmap_path)
106
+ with_lines_removed = all_lines.grep_v(/pin ["']#{package}["']/)
107
+
108
+ File.open(@importmap_path, "w") do |file|
109
+ with_lines_removed.each { |line| file.write(line) }
110
+ end
111
+ end
112
+
113
+ def download_package_file(package, url)
114
+ response = Net::HTTP.get_response(URI(url))
115
+
116
+ if response.code == "200"
117
+ save_vendored_package(package, response.body)
118
+ else
119
+ handle_failure_response(response)
120
+ end
121
+ end
122
+
123
+ def save_vendored_package(package, source)
124
+ File.open(vendored_package_path(package), "w+") do |vendored_package|
125
+ vendored_package.write remove_sourcemap_comment_from(source).force_encoding("UTF-8")
126
+ end
127
+ end
128
+
129
+ def remove_sourcemap_comment_from(source)
130
+ source.gsub(/^\/\/# sourceMappingURL=.*/, "")
131
+ end
132
+
133
+ def vendored_package_path(package)
134
+ @vendor_path.join(package_filename(package))
135
+ end
136
+
137
+ def package_filename(package)
138
+ package.gsub("/", "--") + ".js"
139
+ end
140
+
141
+ def extract_package_version_from(url)
142
+ url.match(/@\d+\.\d+\.\d+/)&.to_a&.first
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,20 @@
1
+ class Importmap::Reloader
2
+ delegate :execute_if_updated, :execute, :updated?, to: :updater
3
+
4
+ def reload!
5
+ import_map_paths.each { |path| Importmap.framework.application.importmap.draw(path) }
6
+ end
7
+
8
+ private
9
+ def updater
10
+ @updater ||= config.file_watcher.new(import_map_paths) { reload! }
11
+ end
12
+
13
+ def import_map_paths
14
+ config.importmap.paths
15
+ end
16
+
17
+ def config
18
+ Importmap.framework.application.config
19
+ end
20
+ end
@@ -0,0 +1,3 @@
1
+ module Importmap
2
+ VERSION = "0.1.0"
3
+ end
data/lib/importmap.rb ADDED
@@ -0,0 +1,24 @@
1
+ $stdout.sync = true unless ENV["IMPORTMAP_STDOUT_SYNC"] == "0"
2
+
3
+ $:.unshift(File.expand_path("../", __FILE__))
4
+
5
+ require "importmap/autoloader"
6
+ Importmap::Autoloader.setup
7
+
8
+ require "memoist"
9
+ require "pathname"
10
+ require "rainbow/ext/string"
11
+
12
+ module Importmap
13
+ class Error < StandardError; end
14
+
15
+ def framework
16
+ Framework.instance.framework_class
17
+ end
18
+
19
+ def framework_boot
20
+ Framework.instance.boot
21
+ end
22
+
23
+ extend self
24
+ end
data/spec/cli_spec.rb ADDED
@@ -0,0 +1,26 @@
1
+ describe Importmap::CLI do
2
+ before(:all) do
3
+ @args = "--from Tung"
4
+ end
5
+
6
+ describe "importmap" do
7
+ it "hello" do
8
+ out = execute("exe/importmap hello world #{@args}")
9
+ expect(out).to include("from: Tung\nHello world")
10
+ end
11
+
12
+ commands = {
13
+ "hell" => "hello",
14
+ "hello" => "name",
15
+ "hello -" => "--from",
16
+ "hello name" => "--from",
17
+ "hello name --" => "--from",
18
+ }
19
+ commands.each do |command, expected_word|
20
+ it "completion #{command}" do
21
+ out = execute("exe/importmap completion #{command}")
22
+ expect(out).to include(expected_word) # only checking for one word for simplicity
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,29 @@
1
+ ENV["IMPORTMAP_TEST"] = "1"
2
+
3
+ # CodeClimate test coverage: https://docs.codeclimate.com/docs/configuring-test-coverage
4
+ # require 'simplecov'
5
+ # SimpleCov.start
6
+
7
+ require "pp"
8
+ require "byebug"
9
+ root = File.expand_path("../", File.dirname(__FILE__))
10
+ require "#{root}/lib/importmap"
11
+
12
+ module Helper
13
+ def execute(cmd)
14
+ puts "Running: #{cmd}" if show_command?
15
+ out = `#{cmd}`
16
+ puts out if show_command?
17
+ out
18
+ end
19
+
20
+ # Added SHOW_COMMAND because DEBUG is also used by other libraries like
21
+ # bundler and it shows its internal debugging logging also.
22
+ def show_command?
23
+ ENV['DEBUG'] || ENV['SHOW_COMMAND']
24
+ end
25
+ end
26
+
27
+ RSpec.configure do |c|
28
+ c.include Helper
29
+ end