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,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "hanami/cli"
5
+ require_relative "../packager"
6
+ require_relative "importmap"
7
+
8
+ module Hanami
9
+ module Importmap
10
+ module CLI
11
+ # `hanami importmap install`
12
+ #
13
+ # Switches an app (or slice) generated by `hanami new` from esbuild to import maps.
14
+ #
15
+ # @api private
16
+ class Install < Hanami::CLI::Commands::App::Command
17
+ include SlicePaths
18
+
19
+ desc "Switch the app (or a slice) from esbuild to import maps"
20
+
21
+ option :slice, required: false, desc: "Slice name"
22
+
23
+ # Matches the import of CSS into JS that only esbuild understands.
24
+ CSS_IMPORT = /^\s*import\s+["'][^"']+\.css["'];?\s*\n/
25
+ LAYOUT_SCRIPT = /^\s*<%=\s*javascript_tag[\s(]+["']app["']\)?\s*%>\s*\n/
26
+ APP_CLASS = /^(?<indent>\s*)class App < Hanami::App\n/
27
+ CSP_NONCE = <<~RUBY
28
+ # Import maps are inline <script> tags. Allow them with a per-request nonce, which
29
+ # `javascript_importmap_tags` adds for you.
30
+ config.actions.content_security_policy[:script_src] += " 'nonce'"
31
+ RUBY
32
+ NPM_SETUP = /^announce "\\nRunning npm install\.\.\."\nnpm install\n\n?/
33
+
34
+ def call(slice: nil, **)
35
+ require_in_app_config unless slice
36
+ write_importmap(slice)
37
+ update_entry_point(slice)
38
+ update_layout(slice)
39
+ remove_node_setup unless slice
40
+
41
+ stdout.puts <<~TEXT
42
+
43
+ Import maps are set up. Pin packages with `bundle exec hanami importmap pin <package>`,
44
+ and run `bin/dev` (or `bundle exec hanami assets compile`) to build them.
45
+ TEXT
46
+ end
47
+
48
+ private
49
+
50
+ # Requires the gem, and allows the import map's inline `<script>` tags under Hanami's default
51
+ # Content Security Policy (`script-src 'self'`). Adding `'nonce'` to a policy makes Hanami add
52
+ # its nonce middleware, and `javascript_importmap_tags` uses the request's nonce.
53
+ def require_in_app_config
54
+ path = "config/app.rb"
55
+ return unless fs.exist?(path)
56
+
57
+ content = fs.read(path)
58
+
59
+ unless content.include?(%(require "hanami/importmap"))
60
+ if content.match?(/^require "hanami"\n/)
61
+ content = content.sub(/^require "hanami"\n/) { "#{_1}require \"hanami/importmap\"\n" }
62
+ else
63
+ note "Add `require \"hanami/importmap\"` to #{path}"
64
+ end
65
+ end
66
+
67
+ unless content.include?("'nonce'")
68
+ if content.match?(APP_CLASS)
69
+ content = content.sub(APP_CLASS) {
70
+ match = $~
71
+ separator = match.post_match.match?(/\A\s*end\b/) ? "" : "\n"
72
+ "#{match[0]}#{CSP_NONCE.gsub(/^/, "#{match[:indent]} ")}#{separator}"
73
+ }
74
+ else
75
+ note "Add 'nonce' to config.actions.content_security_policy[:script_src] in #{path}"
76
+ end
77
+ end
78
+
79
+ fs.write(path, content) unless content == fs.read(path)
80
+ end
81
+
82
+ def write_importmap(slice)
83
+ path = importmap_path(slice)
84
+ return note("#{path} already exists") if path.file?
85
+
86
+ fs.create(path.to_s, "#{Packager::IMPORTMAP_HEADER}\npin \"app\"\n")
87
+ end
88
+
89
+ def update_entry_point(slice)
90
+ path = assets_dir(slice).join("js", "app.js")
91
+
92
+ unless path.file?
93
+ fs.create(path.to_s, %(// The "app" pin in #{importmap_path(slice)} points here.\n))
94
+ return
95
+ end
96
+
97
+ content = path.read
98
+ return unless content.match?(CSS_IMPORT)
99
+
100
+ # Stylesheets load with `stylesheet_tag` in the layout; import maps only carry JavaScript.
101
+ content = content.gsub(CSS_IMPORT, "")
102
+ content = %(// The "app" pin in #{importmap_path(slice)} points here.\n) if content.strip.empty?
103
+ fs.write(path.to_s, content)
104
+ end
105
+
106
+ def update_layout(slice)
107
+ layout = (slice ? slice_root(slice) : Pathname("app")).join("templates", "layouts", "app.html.erb")
108
+ unless layout.file?
109
+ return note("Add `<%= javascript_importmap_tags %>` to your layout's <head>")
110
+ end
111
+
112
+ content = layout.read
113
+ return if content.include?("javascript_importmap_tags")
114
+
115
+ unless content.include?("</head>")
116
+ return note("Add `<%= javascript_importmap_tags %>` to #{layout}")
117
+ end
118
+
119
+ indent = content[/^(\s*)<\/head>/, 1].to_s
120
+ content = content
121
+ .sub(LAYOUT_SCRIPT, "")
122
+ .sub(/^#{indent}<\/head>/) { "#{indent} <%= javascript_importmap_tags %>\n#{_1}" }
123
+
124
+ fs.write(layout.to_s, content)
125
+ end
126
+
127
+ # Removes the Node toolchain `hanami new` sets up, but only files that are still as generated.
128
+ def remove_node_setup
129
+ remove_if("config/assets.js") { stock_assets_js?(_1) }
130
+ removed_package = remove_if("package.json") { stock_package_json?(_1) }
131
+ remove_if("package-lock.json") { removed_package }
132
+
133
+ if fs.exist?("bin/setup") && fs.read("bin/setup").match?(NPM_SETUP)
134
+ fs.write("bin/setup", fs.read("bin/setup").sub(NPM_SETUP, ""))
135
+ end
136
+
137
+ note "You can delete node_modules/" if removed_package && fs.directory?("node_modules")
138
+ end
139
+
140
+ def remove_if(path)
141
+ return false unless fs.exist?(path)
142
+
143
+ if yield(fs.read(path))
144
+ fs.delete(path)
145
+ stdout.puts " remove #{path}"
146
+ true
147
+ else
148
+ note "#{path} has been customized, so it was kept. Remove it if you no longer need Node."
149
+ false
150
+ end
151
+ end
152
+
153
+ def stock_assets_js?(content)
154
+ code = content.lines.map(&:strip).reject { _1.empty? || _1.start_with?("//") }
155
+ code == [
156
+ %(import * as assets from "hanami-assets";),
157
+ "await assets.run({",
158
+ "esbuildOptionsFn: (args, esbuildOptions) => {",
159
+ "return esbuildOptions;",
160
+ "},",
161
+ "});"
162
+ ]
163
+ end
164
+
165
+ def stock_package_json?(content)
166
+ json = JSON.parse(content)
167
+ (json.keys - %w[name private type dependencies]).empty? &&
168
+ (json.fetch("dependencies", {}).keys - %w[hanami-assets esbuild]).empty?
169
+ rescue JSON::ParserError
170
+ false
171
+ end
172
+
173
+ def note(message)
174
+ stdout.puts " note #{message}"
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hanami/cli"
4
+ require_relative "cli/assets"
5
+ require_relative "cli/importmap"
6
+ require_relative "cli/install"
7
+
8
+ # Replace the Node-based assets commands. These registrations run after hanami-cli's own (the
9
+ # `hanami` executable loads the `:cli` group after registering its commands), and Dry CLI replaces
10
+ # an existing command registered under the same name.
11
+ Hanami::CLI.register "assets compile", Hanami::Importmap::CLI::Compile
12
+ Hanami::CLI.register "assets watch", Hanami::Importmap::CLI::Watch
13
+
14
+ Hanami::CLI.register "importmap install", Hanami::Importmap::CLI::Install
15
+ Hanami::CLI.register "importmap pin", Hanami::Importmap::CLI::Pin
16
+ Hanami::CLI.register "importmap unpin", Hanami::Importmap::CLI::Unpin
17
+ Hanami::CLI.register "importmap json", Hanami::Importmap::CLI::Json
18
+
19
+ require_relative "cli/hooks"
20
+ Hanami::CLI.after "install", Hanami::Importmap::CLI::Hooks::AfterInstall
21
+ Hanami::CLI.after "generate slice", Hanami::Importmap::CLI::Hooks::AfterGenerateSlice
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "pathname"
7
+ require_relative "errors"
8
+ require_relative "map"
9
+
10
+ module Hanami
11
+ module Importmap
12
+ # Builds one app's or slice's assets: the pure-Ruby counterpart of hanami-assets-js.
13
+ #
14
+ # Copies every file under the assets source root into the destination directory, fingerprinting
15
+ # filenames in compile mode, and writes the `assets.json` manifest that hanami-assets reads. CSS
16
+ # `url(...)` and `@import` references to other assets are rewritten to their output paths. When
17
+ # an import map config is given, the pins are resolved against the manifest and written to
18
+ # `importmap.json` beside it.
19
+ #
20
+ # Conforms to the hanami-assets bundler specification (hanami-assets SPEC.md): one isolated
21
+ # invocation per app or slice (§3.1), logical names with the type directory stripped (§5.3),
22
+ # fingerprints of 8 uppercase hex characters of SHA-256 (§4.2), atomic manifests (§5.5) and
23
+ # `sri` entries only when requested (§8).
24
+ #
25
+ # @api public
26
+ class Compiler
27
+ MANIFEST = "assets.json"
28
+ IMPORTMAP = "importmap.json"
29
+
30
+ # Sources that need a build step, which import maps deliberately don't have.
31
+ UNSUPPORTED_EXTENSIONS = %w[.ts .tsx .jsx .mts .cts .scss .sass .less].freeze
32
+
33
+ CSS_URL = /url\(\s*(?<quote>["']?)(?<ref>[^"')]+?)\k<quote>\s*\)/
34
+ CSS_IMPORT = /@import\s+(?<quote>["'])(?<ref>[^"']+)\k<quote>/
35
+ EXTERNAL_REF = %r{\A(?:[a-z][a-z0-9+.-]*:|//|/|#)}i
36
+
37
+ # The result of a build.
38
+ Result = Data.define(:manifest, :importmap)
39
+
40
+ # @param source [String, Pathname] the assets source root, e.g. `app/assets`
41
+ # @param destination [String, Pathname] where output goes, e.g. `public/assets/_admin`
42
+ # @param public_dir [String, Pathname] the directory the web server serves (`public/`); manifest
43
+ # URLs are paths relative to it
44
+ # @param importmap_path [String, Pathname, nil] the `config/importmap.rb` to build, if any
45
+ # @param fingerprint [Boolean] embed content digests in filenames (compile mode) or not (watch)
46
+ # @param sri [Array<String, Symbol>] subresource integrity algorithms, e.g. `[:sha384]`
47
+ def initialize(source:, destination:, public_dir:, importmap_path: nil, fingerprint: true, sri: [])
48
+ @source = Pathname(source).expand_path
49
+ @destination = Pathname(destination).expand_path
50
+ @public_dir = Pathname(public_dir).expand_path
51
+ @importmap_path = importmap_path && Pathname(importmap_path).expand_path
52
+ @fingerprint = fingerprint
53
+ @sri = Array(sri).map(&:to_s)
54
+
55
+ @sri.each do |algorithm|
56
+ raise Error, "Unsupported subresource integrity algorithm: #{algorithm}" unless SRI_DIGESTS.key?(algorithm)
57
+ end
58
+ end
59
+
60
+ # Builds the assets and returns the manifest and import map that were written.
61
+ #
62
+ # @return [Result]
63
+ #
64
+ # @raise [Error] if a source can't be built or a pin doesn't resolve
65
+ def call
66
+ previous = read_json(destination.join(MANIFEST))
67
+
68
+ @sources = collect_sources
69
+ @entries = {}
70
+ @in_progress = {}
71
+ @sources.each_key { compile(_1) }
72
+
73
+ manifest = @entries.sort.to_h
74
+ importmap = build_importmap(manifest)
75
+
76
+ FileUtils.mkdir_p(destination)
77
+ write_atomically(destination.join(MANIFEST), JSON.pretty_generate(manifest))
78
+
79
+ if importmap
80
+ write_atomically(destination.join(IMPORTMAP), JSON.pretty_generate(importmap))
81
+ else
82
+ FileUtils.rm_f(destination.join(IMPORTMAP))
83
+ end
84
+
85
+ remove_stale_outputs(previous, manifest) unless fingerprint
86
+
87
+ Result.new(manifest:, importmap:)
88
+ end
89
+
90
+ # Returns the logical name for a path relative to an assets source root: the path with its
91
+ # first (asset type) directory removed, so `js/controllers/hello.js` → `controllers/hello.js`
92
+ # and `images/logo.png` → `logo.png`.
93
+ #
94
+ # @api private
95
+ def self.logical_name(relative_path)
96
+ parts = relative_path.to_s.split("/")
97
+ (parts.length > 1) ? parts.drop(1).join("/") : parts.first
98
+ end
99
+
100
+ SRI_DIGESTS = {
101
+ "sha256" => Digest::SHA256,
102
+ "sha384" => Digest::SHA384,
103
+ "sha512" => Digest::SHA512
104
+ }.freeze
105
+ private_constant :SRI_DIGESTS
106
+
107
+ private
108
+
109
+ attr_reader :source, :destination, :public_dir, :importmap_path, :fingerprint, :sri
110
+
111
+ # Returns {logical name => source path}, in a stable order.
112
+ def collect_sources
113
+ unless source.directory?
114
+ raise Error, "Assets source directory not found: #{source}"
115
+ end
116
+
117
+ sources = {}
118
+ source.glob("**/*").sort.each do |path|
119
+ next unless path.file?
120
+
121
+ relative = path.relative_path_from(source)
122
+ next if relative.each_filename.any? { _1.start_with?(".") }
123
+ next if path.extname == ".map"
124
+
125
+ if UNSUPPORTED_EXTENSIONS.include?(path.extname)
126
+ raise Error, "#{relative} needs a build step, which hanami-importmap doesn't have. " \
127
+ "Write plain JavaScript and CSS, or use hanami-assets-js (esbuild) instead."
128
+ end
129
+
130
+ name = self.class.logical_name(relative)
131
+ if (existing = sources[name])
132
+ raise Error, "#{relative} and #{existing.relative_path_from(source)} would both be served as #{name.inspect}. " \
133
+ "Rename one of them."
134
+ end
135
+
136
+ sources[name] = path
137
+ end
138
+ sources
139
+ end
140
+
141
+ # Compiles one source (once) and returns its manifest entry. CSS is compiled after the assets
142
+ # it references, since its rewritten content (and so its fingerprint) depends on their paths.
143
+ def compile(name)
144
+ return @entries[name] if @entries.key?(name)
145
+ return if @in_progress[name] # a CSS import cycle; leave the reference as written
146
+
147
+ path = @sources.fetch(name)
148
+ content = path.binread
149
+
150
+ if path.extname == ".css"
151
+ @in_progress[name] = true
152
+ content = rewrite_css(path, content)
153
+ @in_progress.delete(name)
154
+ end
155
+
156
+ output_name = fingerprint ? fingerprinted(name, content) : name
157
+ output = destination.join(output_name)
158
+ write_if_changed(output, content)
159
+
160
+ entry = {"url" => "/#{output.relative_path_from(public_dir)}"}
161
+ entry["sri"] = sri.map { "#{_1}-#{SRI_DIGESTS.fetch(_1).base64digest(content)}" } if sri.any?
162
+ @entries[name] = entry
163
+ end
164
+
165
+ def fingerprinted(name, content)
166
+ digest = Digest::SHA256.hexdigest(content)[0, 8].upcase
167
+ ext = File.extname(name)
168
+ "#{name.delete_suffix(ext)}-#{digest}#{ext}"
169
+ end
170
+
171
+ def rewrite_css(path, content)
172
+ content = content.dup.force_encoding(Encoding::UTF_8)
173
+
174
+ content
175
+ .gsub(CSS_URL) { css_reference(path, $~, "url(%s)") }
176
+ .gsub(CSS_IMPORT) { css_reference(path, $~, "@import %s") }
177
+ end
178
+
179
+ def css_reference(css_path, match, format)
180
+ ref = match[:ref]
181
+ return match[0] if ref.match?(EXTERNAL_REF)
182
+
183
+ file, suffix = ref.split(/(?=[?#])/, 2)
184
+ target = css_path.dirname.join(file).expand_path
185
+ name = @sources.key(target)
186
+ entry = name && compile(name)
187
+ return match[0] unless entry
188
+
189
+ quote = match[:quote].empty? ? "" : match[:quote]
190
+ format % "#{quote}#{entry["url"]}#{suffix}#{quote}"
191
+ end
192
+
193
+ def build_importmap(manifest)
194
+ return unless importmap_path&.file?
195
+
196
+ map = Map.load(importmap_path, source_root: source)
197
+ pins = map.pins.map { |pin|
198
+ if pin.remote?
199
+ {"name" => pin.name, "url" => pin.to, "preload" => pin.preload, "integrity" => pin.integrity}.compact
200
+ else
201
+ unless manifest.key?(pin.to)
202
+ raise Error, %(#{importmap_path.basename}: pin "#{pin.name}" points to #{pin.to.inspect}, ) +
203
+ "which isn't an asset in #{source}. Expected a file such as #{source.join("js", pin.to)}."
204
+ end
205
+
206
+ {"name" => pin.name, "asset" => pin.to, "preload" => pin.preload}
207
+ end
208
+ }
209
+
210
+ {"version" => 1, "pins" => pins}
211
+ end
212
+
213
+ def write_if_changed(path, content)
214
+ return if path.file? && path.size == content.bytesize && path.binread == content
215
+
216
+ FileUtils.mkdir_p(path.dirname)
217
+ write_atomically(path, content)
218
+ end
219
+
220
+ def write_atomically(path, content)
221
+ tmp = path.dirname.join(".#{path.basename}.#{Process.pid}.tmp")
222
+ tmp.binwrite(content)
223
+ File.rename(tmp, path)
224
+ ensure
225
+ FileUtils.rm_f(tmp) if tmp&.exist?
226
+ end
227
+
228
+ # In watch mode, outputs keep their names, so delete the ones whose sources are gone.
229
+ # Only paths this build wrote before (per the old manifest) are ever removed.
230
+ def remove_stale_outputs(previous, manifest)
231
+ return unless previous.is_a?(Hash)
232
+
233
+ current = manifest.values.map { _1["url"] }
234
+ previous.each_value do |entry|
235
+ url = entry.is_a?(Hash) && entry["url"]
236
+ next if !url || current.include?(url)
237
+
238
+ path = public_dir.join(url.delete_prefix("/")).expand_path
239
+ FileUtils.rm_f(path) if path.to_s.start_with?("#{destination}/")
240
+ end
241
+ end
242
+
243
+ def read_json(path)
244
+ JSON.parse(path.read) if path.file?
245
+ rescue JSON::ParserError
246
+ nil
247
+ end
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ module Importmap
5
+ # Base error for hanami-importmap.
6
+ #
7
+ # @api public
8
+ class Error < StandardError; end
9
+
10
+ # Raised when rendering import map tags before the import map has been built.
11
+ #
12
+ # @api public
13
+ class MissingImportmapError < Error; end
14
+ end
15
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "json"
5
+ require "hanami/view"
6
+ require_relative "resolver"
7
+
8
+ module Hanami
9
+ module Importmap
10
+ # View helpers for import maps, included in the standard Hanami helpers (so available in
11
+ # templates, parts and scopes).
12
+ #
13
+ # @api public
14
+ module Helpers
15
+ # Renders the import map, `modulepreload` links for preloaded pins, and a module script that
16
+ # imports the entry point.
17
+ #
18
+ # The map comes from the current app's or slice's assets, so each slice gets its own.
19
+ #
20
+ # @example
21
+ # <%= javascript_importmap_tags %>
22
+ # <%= javascript_importmap_tags "admin" %>
23
+ #
24
+ # @param entry_point [String, nil] the pin to import; `nil` renders the map and preloads only
25
+ # @param nonce [String, Boolean, nil] the CSP nonce for the inline scripts. Defaults to the
26
+ # current request's nonce, if any; `false` omits it
27
+ #
28
+ # @return [Hanami::View::HTML::SafeString]
29
+ def javascript_importmap_tags(entry_point = "app", nonce: nil)
30
+ map = Resolver.call(_context.assets)
31
+ # `false` rather than nil, so the tag helpers don't look the nonce up again.
32
+ nonce = _importmap_nonce(nonce) || false
33
+
34
+ tags = [javascript_importmap_tag(map, nonce:)]
35
+ tags.concat(map.preloads.map { _module_preload_tag(_1, map.integrity[_1], nonce || nil) })
36
+ tags << javascript_import_module_tag(entry_point, nonce:) if entry_point
37
+
38
+ ::Hanami::View::HTML::SafeString.new(tags.join("\n"))
39
+ end
40
+
41
+ # Renders only the `<script type="importmap">` tag.
42
+ #
43
+ # @return [Hanami::View::HTML::SafeString]
44
+ def javascript_importmap_tag(map = Resolver.call(_context.assets), nonce: nil)
45
+ nonce = _importmap_nonce(nonce)
46
+ json = JSON.generate(map.to_h, script_safe: true)
47
+
48
+ # A page can't change its import map once modules have loaded, so ask Turbo Drive for a full
49
+ # reload when the next page's map differs (another slice's, or a rebuilt one). Turbo ignores
50
+ # nonces when comparing, so pages sharing a map still get fast visits.
51
+ ::Hanami::View::HTML::SafeString.new(
52
+ %(<script type="importmap"#{_importmap_attrs("data-turbo-track": "reload", nonce:)}>#{json}</script>)
53
+ )
54
+ end
55
+
56
+ # Renders a module script that imports the given pin.
57
+ #
58
+ # @return [Hanami::View::HTML::SafeString]
59
+ def javascript_import_module_tag(name, nonce: nil)
60
+ nonce = _importmap_nonce(nonce)
61
+
62
+ ::Hanami::View::HTML::SafeString.new(
63
+ %(<script type="module"#{_importmap_attrs(nonce:)}>import #{JSON.generate(name.to_s, script_safe: true)}</script>)
64
+ )
65
+ end
66
+
67
+ private
68
+
69
+ def _module_preload_tag(url, integrity, nonce)
70
+ %(<link rel="modulepreload"#{_importmap_attrs(href: url, integrity:, nonce:)}>)
71
+ end
72
+
73
+ def _importmap_nonce(nonce)
74
+ return nil if nonce == false
75
+ return nonce if nonce.is_a?(String)
76
+
77
+ content_security_policy_nonce if respond_to?(:content_security_policy_nonce, true)
78
+ end
79
+
80
+ def _importmap_attrs(**attrs)
81
+ attrs.compact.map { |key, value| %( #{key}="#{CGI.escapeHTML(value.to_s)}") }.join
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ Hanami::View::Scope.include(Hanami::Importmap::Helpers)
88
+ Hanami::Extensions::View::StandardHelpers.include(Hanami::Importmap::Helpers)
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "errors"
5
+
6
+ module Hanami
7
+ module Importmap
8
+ # The `config/importmap.rb` DSL.
9
+ #
10
+ # Pins name a module and point it at an asset by its logical name (the same name you'd give to
11
+ # `javascript_tag`), or at a full URL.
12
+ #
13
+ # @example
14
+ # pin "app" # → app.js
15
+ # pin "@hotwired/stimulus", to: "@hotwired--stimulus.js"
16
+ # pin "lodash", to: "https://ga.jspm.io/npm:lodash@4.17.21/lodash.js", preload: false
17
+ # pin_all_from "js/controllers", under: "controllers"
18
+ #
19
+ # @api public
20
+ class Map
21
+ # A single pinned module.
22
+ Pin = Data.define(:name, :to, :preload, :integrity) do
23
+ def remote?
24
+ Map.remote?(to)
25
+ end
26
+ end
27
+
28
+ JS_EXTENSIONS = %w[.js .mjs].freeze
29
+
30
+ # @api private
31
+ def self.remote?(to)
32
+ to.match?(%r{\A(?:https?:)?//}i) || to.start_with?("/")
33
+ end
34
+
35
+ # Evaluates an import map config file.
36
+ #
37
+ # @param path [String, Pathname] the `config/importmap.rb` file
38
+ # @param source_root [String, Pathname] the assets source root that `pin_all_from` paths are
39
+ # relative to
40
+ #
41
+ # @return [Map]
42
+ def self.load(path, source_root:)
43
+ path = Pathname(path)
44
+ new(source_root:).tap { |map|
45
+ map.instance_eval(path.read, path.to_s, 1)
46
+ }
47
+ end
48
+
49
+ # @api private
50
+ attr_reader :source_root
51
+
52
+ def initialize(source_root:)
53
+ @source_root = Pathname(source_root)
54
+ @pins = {}
55
+ end
56
+
57
+ # Pins a module name to an asset or URL. Pinning the same name again replaces the earlier pin.
58
+ #
59
+ # @param name [String] the module specifier, as used in `import` statements
60
+ # @param to [String, nil] the asset's logical name (defaults to `"#{name}.js"`) or a URL
61
+ # @param preload [Boolean] add a `<link rel="modulepreload">` for the module
62
+ # @param integrity [String, nil] an SRI hash for a remote URL (local assets get theirs from the
63
+ # manifest when `config.assets.subresource_integrity` is set)
64
+ def pin(name, to: nil, preload: true, integrity: nil)
65
+ name = name.to_s
66
+ to = (to || "#{name}.js").to_s
67
+
68
+ if integrity && !Map.remote?(to)
69
+ raise Error, %(pin "#{name}": integrity: is only for URLs; local assets get theirs from config.assets.subresource_integrity)
70
+ end
71
+
72
+ @pins[name] = Pin.new(name:, to:, preload:, integrity:)
73
+ end
74
+
75
+ # Pins every JavaScript file in a directory.
76
+ #
77
+ # Each file's name is its path within the directory, without the extension, prefixed with
78
+ # `under/` if given. `index.js` files are pinned as their directory's name.
79
+ #
80
+ # @example
81
+ # pin_all_from "js/controllers", under: "controllers"
82
+ # # js/controllers/hello_controller.js → pin "controllers/hello_controller"
83
+ # # js/controllers/index.js → pin "controllers"
84
+ #
85
+ # @param dir [String] a directory relative to the assets source root
86
+ # @param under [String, nil] a prefix for the pinned names
87
+ # @param preload [Boolean]
88
+ def pin_all_from(dir, under: nil, preload: true)
89
+ root = source_root.join(dir)
90
+
91
+ unless root.directory?
92
+ raise Error, %(pin_all_from "#{dir}": no such directory #{root})
93
+ end
94
+
95
+ root.glob("**/*").sort.each do |path|
96
+ next unless path.file? && JS_EXTENSIONS.include?(path.extname)
97
+
98
+ relative = path.relative_path_from(root).sub_ext("").to_s
99
+ relative = (relative == "index") ? "" : relative.delete_suffix("/index")
100
+ name = [under, relative].reject { _1.nil? || _1.empty? }.join("/")
101
+ next if name.empty?
102
+
103
+ pin name, to: Compiler.logical_name(path.relative_path_from(source_root)), preload:
104
+ end
105
+ end
106
+
107
+ # @return [Array<Pin>]
108
+ def pins
109
+ @pins.values
110
+ end
111
+ end
112
+ end
113
+ end