hanami-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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE.md +9 -0
- data/README.md +186 -0
- data/lib/hanami/importmap/cli/assets.rb +150 -0
- data/lib/hanami/importmap/cli/hooks.rb +39 -0
- data/lib/hanami/importmap/cli/importmap.rb +139 -0
- data/lib/hanami/importmap/cli/install.rb +179 -0
- data/lib/hanami/importmap/cli.rb +21 -0
- data/lib/hanami/importmap/compiler.rb +250 -0
- data/lib/hanami/importmap/errors.rb +15 -0
- data/lib/hanami/importmap/helpers.rb +88 -0
- data/lib/hanami/importmap/map.rb +113 -0
- data/lib/hanami/importmap/packager.rb +151 -0
- data/lib/hanami/importmap/resolver.rb +108 -0
- data/lib/hanami/importmap/version.rb +7 -0
- data/lib/hanami/importmap.rb +27 -0
- data/lib/hanami-importmap.rb +9 -0
- metadata +101 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "pathname"
|
|
7
|
+
require "uri"
|
|
8
|
+
require_relative "errors"
|
|
9
|
+
|
|
10
|
+
module Hanami
|
|
11
|
+
module Importmap
|
|
12
|
+
# Resolves npm packages to ES module URLs with the jspm.io generator API, vendors them into an
|
|
13
|
+
# assets directory, and keeps `config/importmap.rb` pin lines in step.
|
|
14
|
+
#
|
|
15
|
+
# A port of importmap-rails' `Importmap::Packager`.
|
|
16
|
+
#
|
|
17
|
+
# @api private
|
|
18
|
+
class Packager
|
|
19
|
+
ENDPOINT = URI("https://api.jspm.io/generate")
|
|
20
|
+
|
|
21
|
+
IMPORTMAP_HEADER = <<~RUBY
|
|
22
|
+
# frozen_string_literal: true
|
|
23
|
+
|
|
24
|
+
# Pin JavaScript modules for this import map. `pin` names what you `import`; `to:` is an
|
|
25
|
+
# asset name, as you'd give to `javascript_tag` (defaults to the name plus ".js").
|
|
26
|
+
#
|
|
27
|
+
# Vendor npm packages with `bundle exec hanami importmap pin <package>`.
|
|
28
|
+
RUBY
|
|
29
|
+
|
|
30
|
+
attr_reader :importmap_path, :vendor_path
|
|
31
|
+
|
|
32
|
+
# @param importmap_path [String, Pathname] the `config/importmap.rb` to update
|
|
33
|
+
# @param vendor_path [String, Pathname] where to download packages (`<assets>/vendor`)
|
|
34
|
+
def initialize(importmap_path:, vendor_path:)
|
|
35
|
+
@importmap_path = Pathname(importmap_path)
|
|
36
|
+
@vendor_path = Pathname(vendor_path)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Resolves packages and their dependencies.
|
|
40
|
+
#
|
|
41
|
+
# @return [Hash{String => String}, nil] package name → URL, or nil if nothing was found
|
|
42
|
+
def import(*packages, from: "jspm", env: "production")
|
|
43
|
+
response = post_json(
|
|
44
|
+
"install" => packages,
|
|
45
|
+
"flattenScope" => true,
|
|
46
|
+
"env" => ["browser", "module", env],
|
|
47
|
+
"provider" => (from.to_s == "jspm") ? "jspm.io" : from.to_s
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
case response.code
|
|
51
|
+
when "200" then JSON.parse(response.body).dig("map", "imports")
|
|
52
|
+
when "404", "401" then nil
|
|
53
|
+
else raise_failure(response)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Downloads a package into the vendor directory, replacing any earlier version.
|
|
58
|
+
def download(package, url)
|
|
59
|
+
FileUtils.mkdir_p(vendor_path)
|
|
60
|
+
response = get(url)
|
|
61
|
+
raise_failure(response) unless response.code == "200"
|
|
62
|
+
|
|
63
|
+
source = response.body.dup.force_encoding(Encoding::UTF_8).gsub(%r{^//# sourceMappingURL=.*}, "")
|
|
64
|
+
vendored_path(package).write("// #{package}#{version_from(url)} downloaded from #{url}\n\n#{source}")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Adds or replaces the pin line for a vendored package.
|
|
68
|
+
def pin(package, url, preload: true)
|
|
69
|
+
filename = package_filename(package)
|
|
70
|
+
line = %(pin "#{package}")
|
|
71
|
+
line += %(, to: "#{filename}") unless filename == "#{package}.js"
|
|
72
|
+
line += ", preload: false" unless preload
|
|
73
|
+
line += " # #{version_from(url)}" if version_from(url)
|
|
74
|
+
|
|
75
|
+
write_pin_line(package, line)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Removes a package's file and pin line. Returns false if it wasn't pinned.
|
|
79
|
+
def remove(package)
|
|
80
|
+
FileUtils.rm_f(vendored_path(package))
|
|
81
|
+
return false unless pinned?(package)
|
|
82
|
+
|
|
83
|
+
importmap_path.write(importmap_path.readlines.grep_v(pin_line_regexp(package)).join)
|
|
84
|
+
true
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def pinned?(package)
|
|
88
|
+
importmap_path.file? && importmap_path.readlines.any? { _1.match?(pin_line_regexp(package)) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def package_filename(package)
|
|
92
|
+
"#{package.gsub("/", "--")}.js"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def vendored_path(package)
|
|
96
|
+
vendor_path.join(package_filename(package))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def write_pin_line(package, line)
|
|
102
|
+
unless importmap_path.file?
|
|
103
|
+
FileUtils.mkdir_p(importmap_path.dirname)
|
|
104
|
+
importmap_path.write(IMPORTMAP_HEADER)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
lines = importmap_path.readlines
|
|
108
|
+
index = lines.index { _1.match?(pin_line_regexp(package)) }
|
|
109
|
+
|
|
110
|
+
if index
|
|
111
|
+
lines[index] = "#{line}\n"
|
|
112
|
+
else
|
|
113
|
+
lines << "\n" unless lines.empty? || lines.last.end_with?("\n")
|
|
114
|
+
lines << "#{line}\n"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
importmap_path.write(lines.join)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def pin_line_regexp(package)
|
|
121
|
+
/\A\s*pin\s+["']#{Regexp.escape(package)}["']/
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def version_from(url)
|
|
125
|
+
url[/@\d+\.\d+\.\d+(?:-[\w.]+)?/]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def post_json(body)
|
|
129
|
+
Net::HTTP.post(ENDPOINT, JSON.generate(body), "Content-Type" => "application/json")
|
|
130
|
+
rescue => exception
|
|
131
|
+
raise Error, "Couldn't reach #{ENDPOINT.host} (#{exception.class}: #{exception.message})"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def get(url)
|
|
135
|
+
Net::HTTP.get_response(URI(url))
|
|
136
|
+
rescue => exception
|
|
137
|
+
raise Error, "Couldn't download #{url} (#{exception.class}: #{exception.message})"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def raise_failure(response)
|
|
141
|
+
message = begin
|
|
142
|
+
JSON.parse(response.body.to_s)["error"]
|
|
143
|
+
rescue JSON::ParserError
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
raise Error, message || "Unexpected response from #{ENDPOINT.host} (HTTP #{response.code})"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "hanami/assets"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "compiler"
|
|
7
|
+
|
|
8
|
+
module Hanami
|
|
9
|
+
module Importmap
|
|
10
|
+
# Turns the `importmap.json` written by the {Compiler} into a browser import map, resolving
|
|
11
|
+
# asset names through hanami-assets (so `config.assets.base_url` and subresource integrity
|
|
12
|
+
# apply exactly as they do for `javascript_tag`).
|
|
13
|
+
#
|
|
14
|
+
# Results are cached per assets directory and rebuilt when `importmap.json` or `assets.json`
|
|
15
|
+
# change, so `hanami assets watch` output is picked up without restarting the server.
|
|
16
|
+
#
|
|
17
|
+
# @api public
|
|
18
|
+
class Resolver
|
|
19
|
+
# A resolved import map.
|
|
20
|
+
#
|
|
21
|
+
# @!attribute imports [Hash{String => String}] module name → URL
|
|
22
|
+
# @!attribute integrity [Hash{String => String}] URL → SRI value
|
|
23
|
+
# @!attribute preloads [Array<String>] URLs to preload
|
|
24
|
+
ResolvedMap = Data.define(:imports, :integrity, :preloads) do
|
|
25
|
+
# @return [Hash] the import map, ready to serialize into `<script type="importmap">`
|
|
26
|
+
def to_h
|
|
27
|
+
{"imports" => imports, "integrity" => (integrity unless integrity.empty?)}.compact
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@cache = {}
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# @param assets [Hanami::Assets] the app's or slice's assets (`context.assets` in views)
|
|
36
|
+
#
|
|
37
|
+
# @return [ResolvedMap]
|
|
38
|
+
#
|
|
39
|
+
# @raise [MissingImportmapError] if the import map hasn't been built
|
|
40
|
+
def call(assets)
|
|
41
|
+
root = Pathname(assets.root)
|
|
42
|
+
stamp = [mtime(root.join(Compiler::IMPORTMAP)), mtime(root.join(Compiler::MANIFEST))]
|
|
43
|
+
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
cached = @cache[root.to_s]
|
|
46
|
+
return cached.last if cached && cached.first == stamp
|
|
47
|
+
|
|
48
|
+
# Use fresh assets rather than the given ones, whose manifest is read once and memoized.
|
|
49
|
+
fresh = Hanami::Assets.new(config: assets.config, root:)
|
|
50
|
+
resolved = new(fresh, root).call
|
|
51
|
+
@cache[root.to_s] = [stamp, resolved]
|
|
52
|
+
resolved
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @api private
|
|
57
|
+
def reset!
|
|
58
|
+
@mutex.synchronize { @cache.clear }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def mtime(path)
|
|
64
|
+
path.mtime
|
|
65
|
+
rescue Errno::ENOENT
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def initialize(assets, root)
|
|
71
|
+
@assets = assets
|
|
72
|
+
@root = root
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [ResolvedMap]
|
|
76
|
+
def call
|
|
77
|
+
path = @root.join(Compiler::IMPORTMAP)
|
|
78
|
+
|
|
79
|
+
unless path.file?
|
|
80
|
+
raise MissingImportmapError, "No import map found at #{path}. " \
|
|
81
|
+
"Add a config/importmap.rb and run `hanami assets compile` (or `hanami dev`)."
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
data = JSON.parse(path.read)
|
|
85
|
+
imports = {}
|
|
86
|
+
integrity = {}
|
|
87
|
+
preloads = []
|
|
88
|
+
|
|
89
|
+
data.fetch("pins").each do |pin|
|
|
90
|
+
if pin["asset"]
|
|
91
|
+
asset = @assets[pin["asset"]]
|
|
92
|
+
url = asset.url
|
|
93
|
+
sri = Array(asset.sri).join(" ")
|
|
94
|
+
integrity[url] = sri unless sri.empty?
|
|
95
|
+
else
|
|
96
|
+
url = pin.fetch("url")
|
|
97
|
+
integrity[url] = pin["integrity"] if pin["integrity"]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
imports[pin.fetch("name")] = url
|
|
101
|
+
preloads << url if pin["preload"]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
ResolvedMap.new(imports:, integrity:, preloads: preloads.uniq)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hanami"
|
|
4
|
+
require_relative "importmap/version"
|
|
5
|
+
require_relative "importmap/errors"
|
|
6
|
+
require_relative "importmap/map"
|
|
7
|
+
require_relative "importmap/compiler"
|
|
8
|
+
require_relative "importmap/resolver"
|
|
9
|
+
|
|
10
|
+
module Hanami
|
|
11
|
+
# Import maps for Hanami: ES modules without Node or a bundler.
|
|
12
|
+
#
|
|
13
|
+
# Requiring this file adds `javascript_importmap_tags` to the standard view helpers. The gem also
|
|
14
|
+
# replaces `hanami assets compile` and `hanami assets watch` with a pure-Ruby bundler (see
|
|
15
|
+
# {Compiler}), and adds `hanami importmap` commands.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# # config/app.rb
|
|
19
|
+
# require "hanami"
|
|
20
|
+
# require "hanami/importmap"
|
|
21
|
+
#
|
|
22
|
+
# @api public
|
|
23
|
+
module Importmap
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
require_relative "importmap/helpers" if Hanami.bundled?("hanami-view")
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loaded by Bundler.require (the `hanami` CLI requires the `:cli` group). The app itself requires
|
|
4
|
+
# "hanami/importmap" from config/app.rb.
|
|
5
|
+
require "hanami/cli"
|
|
6
|
+
|
|
7
|
+
if Hanami::CLI.within_hanami_app?
|
|
8
|
+
require_relative "hanami/importmap/cli"
|
|
9
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: hanami-importmap
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Andrea Fomera
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: hanami-assets
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: listen
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.9'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.9'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: dry-inflector
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.0'
|
|
54
|
+
description: A pure-Ruby bundler for hanami-assets that fingerprints your JavaScript,
|
|
55
|
+
CSS and images, plus `config/importmap.rb`, the `javascript_importmap_tags` helper
|
|
56
|
+
and `hanami importmap pin`
|
|
57
|
+
email:
|
|
58
|
+
- afomera@hey.com
|
|
59
|
+
executables: []
|
|
60
|
+
extensions: []
|
|
61
|
+
extra_rdoc_files: []
|
|
62
|
+
files:
|
|
63
|
+
- CHANGELOG.md
|
|
64
|
+
- LICENSE.md
|
|
65
|
+
- README.md
|
|
66
|
+
- lib/hanami-importmap.rb
|
|
67
|
+
- lib/hanami/importmap.rb
|
|
68
|
+
- lib/hanami/importmap/cli.rb
|
|
69
|
+
- lib/hanami/importmap/cli/assets.rb
|
|
70
|
+
- lib/hanami/importmap/cli/hooks.rb
|
|
71
|
+
- lib/hanami/importmap/cli/importmap.rb
|
|
72
|
+
- lib/hanami/importmap/cli/install.rb
|
|
73
|
+
- lib/hanami/importmap/compiler.rb
|
|
74
|
+
- lib/hanami/importmap/errors.rb
|
|
75
|
+
- lib/hanami/importmap/helpers.rb
|
|
76
|
+
- lib/hanami/importmap/map.rb
|
|
77
|
+
- lib/hanami/importmap/packager.rb
|
|
78
|
+
- lib/hanami/importmap/resolver.rb
|
|
79
|
+
- lib/hanami/importmap/version.rb
|
|
80
|
+
homepage: https://github.com/afomera/hanami-importmap
|
|
81
|
+
licenses:
|
|
82
|
+
- MIT
|
|
83
|
+
metadata: {}
|
|
84
|
+
rdoc_options: []
|
|
85
|
+
require_paths:
|
|
86
|
+
- lib
|
|
87
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
88
|
+
requirements:
|
|
89
|
+
- - ">="
|
|
90
|
+
- !ruby/object:Gem::Version
|
|
91
|
+
version: '3.3'
|
|
92
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - ">="
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '0'
|
|
97
|
+
requirements: []
|
|
98
|
+
rubygems_version: 4.0.20
|
|
99
|
+
specification_version: 4
|
|
100
|
+
summary: 'Import maps for Hanami: ship ES modules without Node or a bundler'
|
|
101
|
+
test_files: []
|