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.
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ module Importmap
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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: []