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,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
|