importmap 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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