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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0cf57753c3e8efea348d6763d372617262b055054e1158cbf339a48f10746dd7
|
|
4
|
+
data.tar.gz: 957405604785d2f08a4c7a7e9a318372423d0de69dc99fe7ad3c3055889633b0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: bc2c759e8d900456f46bd75f56c790bd8306a473c528f2a290c4d437ddfdc2cc6dc8116d39625f174f86d373375768e3acb8ab7477452ac53d874c79f99e5334
|
|
7
|
+
data.tar.gz: 37519ebf5df7a16f7de8365ec82946973af0549930bf4b005e211b5a1a36fdc32dbbac1555345e3966644357e92baf43831732c0b074d907a771f92a0016bdd0
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
- Pure-Ruby bundler for hanami-assets, replacing `hanami assets compile` and `hanami assets watch`.
|
|
6
|
+
- `config/importmap.rb` with `pin` and `pin_all_from`, per app and per slice.
|
|
7
|
+
- `javascript_importmap_tags`, with subresource integrity and CSP nonces.
|
|
8
|
+
- `hanami importmap install`, `pin`, `unpin` and `json`.
|
|
9
|
+
- Hooks: `hanami install` switches a new app to import maps; `hanami generate slice` gives new slices their own import map.
|
data/LICENSE.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright © 2026 Andrea Fomera
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# hanami-importmap
|
|
2
|
+
|
|
3
|
+
Import maps for [Hanami](https://hanamirb.org). Ship JavaScript as ES modules, with no Node, no
|
|
4
|
+
npm, and no bundler. This is the Hanami counterpart to
|
|
5
|
+
[importmap-rails](https://github.com/rails/importmap-rails).
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# config/importmap.rb
|
|
9
|
+
pin "app"
|
|
10
|
+
pin "@hotwired/turbo", to: "@hotwired--turbo.js"
|
|
11
|
+
pin "@hotwired/stimulus", to: "@hotwired--stimulus.js"
|
|
12
|
+
pin_all_from "js/controllers", under: "controllers"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```erb
|
|
16
|
+
<%# app/templates/layouts/app.html.erb %>
|
|
17
|
+
<head>
|
|
18
|
+
<%= stylesheet_tag "app" %>
|
|
19
|
+
<%= javascript_importmap_tags %>
|
|
20
|
+
</head>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## How it fits into Hanami
|
|
24
|
+
|
|
25
|
+
hanami-importmap is a **bundler for hanami-assets**, written in Ruby. It does not replace
|
|
26
|
+
hanami-assets. It replaces `hanami assets compile` and `hanami assets watch`, and writes the same
|
|
27
|
+
`public/assets/**/assets.json` manifests that the esbuild bundler writes, following the
|
|
28
|
+
[hanami-assets bundler spec](https://github.com/hanami/hanami-assets/blob/main/SPEC.md).
|
|
29
|
+
|
|
30
|
+
Everything that reads those manifests keeps working as before:
|
|
31
|
+
|
|
32
|
+
- `javascript_tag`, `stylesheet_tag`, `image_tag`, `favicon_tag` and `asset_url`
|
|
33
|
+
- `config.assets.base_url` (CDNs) and `config.assets.subresource_integrity`
|
|
34
|
+
- static file serving
|
|
35
|
+
- slices, which each get their own assets and import map
|
|
36
|
+
- `bin/dev`
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
Requires Hanami 2.3+ with hanami-assets. It is built and tested against Hanami's main branches.
|
|
41
|
+
|
|
42
|
+
Add the gem to the `:cli` group as well as the default group. The `hanami` CLI only loads the
|
|
43
|
+
`:cli` group, and that's how the gem takes over the assets commands.
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
gem "hanami-importmap", group: [:default, :cli]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Then switch the app over:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
bundle install
|
|
53
|
+
bundle exec hanami install # or `bundle exec hanami importmap install`
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
For a new app, run `hanami new bookshelf`, add the gem, and run those two commands. `hanami install`
|
|
57
|
+
switches the app to import maps the first time it runs with the gem bundled, when there's no
|
|
58
|
+
`config/importmap.rb` yet; later runs leave the app alone. `hanami new` has no option for this,
|
|
59
|
+
and a gem can't add one, because Hanami loads plugins from the app's Gemfile and the app doesn't
|
|
60
|
+
exist yet.
|
|
61
|
+
|
|
62
|
+
`install` makes these changes:
|
|
63
|
+
|
|
64
|
+
- **`config/app.rb`**
|
|
65
|
+
- Requires `hanami/importmap`.
|
|
66
|
+
- Adds `'nonce'` to the Content Security Policy's `script-src` (see below).
|
|
67
|
+
- **`config/importmap.rb`:** creates it, with `pin "app"`.
|
|
68
|
+
- **`app/assets/js/app.js`:** removes `import "../css/app.css"`. That import only works with
|
|
69
|
+
esbuild; the layout already loads the CSS with `stylesheet_tag`.
|
|
70
|
+
- **The layout:** swaps `javascript_tag "app"` for `javascript_importmap_tags` in the `<head>`.
|
|
71
|
+
- **Node files:**
|
|
72
|
+
- Removes `config/assets.js`, `package.json`, `package-lock.json`, and `npm install` from
|
|
73
|
+
`bin/setup`.
|
|
74
|
+
- Files you've customized are kept, and the command says so.
|
|
75
|
+
- Delete `node_modules/` yourself.
|
|
76
|
+
|
|
77
|
+
`hanami generate slice` gets the same treatment automatically: the new slice gets its own
|
|
78
|
+
`config/importmap.rb` and `javascript_importmap_tags`. For a slice that existed before you added
|
|
79
|
+
the gem, run `bundle exec hanami importmap install --slice=admin`.
|
|
80
|
+
|
|
81
|
+
## Pinning packages
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
bundle exec hanami importmap pin @hotwired/stimulus @hotwired/turbo
|
|
85
|
+
bundle exec hanami importmap pin canvas-confetti --slice=admin
|
|
86
|
+
bundle exec hanami importmap pin lodash@4.17.21 --no-preload
|
|
87
|
+
bundle exec hanami importmap unpin lodash
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`pin` resolves packages and their dependencies with the [jspm.io](https://jspm.org) generator. It
|
|
91
|
+
downloads them into `app/assets/vendor/` (or `slices/<name>/assets/vendor/`) and adds the `pin`
|
|
92
|
+
lines. Vendored files are your assets: they're fingerprinted, served from your own origin, and
|
|
93
|
+
committed to your repo.
|
|
94
|
+
|
|
95
|
+
`bundle exec hanami importmap json [--slice=admin]` prints the import map from the last build.
|
|
96
|
+
|
|
97
|
+
## The DSL
|
|
98
|
+
|
|
99
|
+
The `to:` value of a pin is an **asset name**, the same name you'd give to `javascript_tag`. Asset
|
|
100
|
+
names are paths under the assets directory, with the first directory removed:
|
|
101
|
+
|
|
102
|
+
| File | Asset name |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `app/assets/js/app.js` | `app.js` |
|
|
105
|
+
| `app/assets/js/controllers/hello_controller.js` | `controllers/hello_controller.js` |
|
|
106
|
+
| `app/assets/vendor/@hotwired--turbo.js` | `@hotwired--turbo.js` |
|
|
107
|
+
| `app/assets/images/logo.png` | `logo.png` |
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
pin "app" # to: "app.js"
|
|
111
|
+
pin "@hotwired/turbo", to: "@hotwired--turbo.js"
|
|
112
|
+
pin "lodash", to: "https://ga.jspm.io/npm:lodash@4.17.21/lodash.js", preload: false,
|
|
113
|
+
integrity: "sha384-…" # a URL, not vendored
|
|
114
|
+
pin_all_from "js/controllers", under: "controllers"
|
|
115
|
+
# js/controllers/hello_controller.js → "controllers/hello_controller"
|
|
116
|
+
# js/controllers/index.js → "controllers"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Pins are preloaded (`<link rel="modulepreload">`) unless you pass `preload: false`.
|
|
120
|
+
|
|
121
|
+
If a pin doesn't match an asset, `hanami assets compile` fails and names the pin. You won't find
|
|
122
|
+
out in the browser.
|
|
123
|
+
|
|
124
|
+
### Slices
|
|
125
|
+
|
|
126
|
+
- A slice with its own `slices/<name>/config/importmap.rb` gets its own map, built from its own
|
|
127
|
+
assets.
|
|
128
|
+
- A slice without one uses the app's `config/importmap.rb`, resolved against the slice's assets.
|
|
129
|
+
This is the same fallback hanami-assets uses for `config/assets.js`.
|
|
130
|
+
- `javascript_importmap_tags` always renders the current slice's map.
|
|
131
|
+
|
|
132
|
+
## What compile and watch do
|
|
133
|
+
|
|
134
|
+
- **`hanami assets compile`**
|
|
135
|
+
- Copies every file under each assets directory to `public/assets/`, with the content digest
|
|
136
|
+
in the filename: `app-F5D98970.js`.
|
|
137
|
+
- Rewrites CSS `url(...)` and `@import` references to other assets to point at their output
|
|
138
|
+
paths.
|
|
139
|
+
- Writes `assets.json`, plus `importmap.json` from your `config/importmap.rb`.
|
|
140
|
+
- Includes `sri` digests when `config.assets.subresource_integrity` is set. These go into the
|
|
141
|
+
import map's `integrity` section too, so the browser checks every module, not just the entry
|
|
142
|
+
point.
|
|
143
|
+
- **`hanami assets watch`**
|
|
144
|
+
- Builds with stable filenames.
|
|
145
|
+
- Rebuilds a slice when a file in its assets directory or its `importmap.rb` changes.
|
|
146
|
+
- The running server picks up new maps without a restart.
|
|
147
|
+
|
|
148
|
+
There's no transpiling and no minification. TypeScript, JSX and Sass files raise an error that
|
|
149
|
+
points you at hanami-assets-js.
|
|
150
|
+
|
|
151
|
+
## Content Security Policy
|
|
152
|
+
|
|
153
|
+
Hanami's default policy is `script-src 'self'`, which blocks inline scripts. Browsers don't support
|
|
154
|
+
external import maps, so the map has to be an inline `<script type="importmap">`.
|
|
155
|
+
|
|
156
|
+
`install` adds `'nonce'` to `script-src`. Hanami then adds its nonce middleware, and
|
|
157
|
+
`javascript_importmap_tags` puts the request's nonce on its tags:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
config.actions.content_security_policy[:script_src] += " 'nonce'"
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Pass `nonce: false` to leave it off, or `nonce: "…"` to set it yourself.
|
|
164
|
+
|
|
165
|
+
## Caveats
|
|
166
|
+
|
|
167
|
+
- **Use bare specifiers between your own modules.** For example, write
|
|
168
|
+
`import "controllers/hello_controller"`, not `import "./hello_controller.js"`. Fingerprinted
|
|
169
|
+
filenames break relative imports. importmap-rails has the same rule, and `pin_all_from` exists
|
|
170
|
+
for this.
|
|
171
|
+
- **Pick one bundler per app.** The override covers the whole app, so you can't use esbuild for one
|
|
172
|
+
slice and import maps for another.
|
|
173
|
+
|
|
174
|
+
## Development
|
|
175
|
+
|
|
176
|
+
```sh
|
|
177
|
+
bundle install
|
|
178
|
+
bundle exec rspec
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The specs include an end-to-end run of the real `hanami` CLI in a temporary app. See
|
|
182
|
+
[hanami-importmap-example](https://github.com/afomera/hanami-importmap-example) for a full app.
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
MIT
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hanami/cli"
|
|
4
|
+
require "hanami/assets"
|
|
5
|
+
require "hanami/cli/commands/app/assets"
|
|
6
|
+
require_relative "../compiler"
|
|
7
|
+
|
|
8
|
+
module Hanami
|
|
9
|
+
module Importmap
|
|
10
|
+
module CLI
|
|
11
|
+
# Where one app's or slice's assets come from and go to.
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
Target = Data.define(:slice, :source, :destination, :public_dir, :importmap_path) do
|
|
15
|
+
# @api private
|
|
16
|
+
def self.for(slice)
|
|
17
|
+
app_root = slice.app.root
|
|
18
|
+
app = slice.eql?(slice.app)
|
|
19
|
+
|
|
20
|
+
new(
|
|
21
|
+
slice:,
|
|
22
|
+
source: app ? app_root.join("app", "assets") : slice.root.join("assets"),
|
|
23
|
+
destination: app_root.join("public", "assets", Hanami::Assets.public_assets_dir(slice).to_s),
|
|
24
|
+
public_dir: app_root.join("public"),
|
|
25
|
+
# A slice without its own import map uses the app's, resolved against the slice's assets,
|
|
26
|
+
# the same way a slice without a config/assets.js uses the app's.
|
|
27
|
+
importmap_path: [slice.root.join("config", "importmap.rb"), app_root.join("config", "importmap.rb")]
|
|
28
|
+
.uniq.find(&:file?)
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def name = slice.slice_name.to_s
|
|
33
|
+
|
|
34
|
+
def compiler(**)
|
|
35
|
+
Compiler.new(source:, destination:, public_dir:, importmap_path:, **)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Shared behaviour for the assets commands, which run the {Compiler} in-process for each slice
|
|
40
|
+
# instead of spawning `node config/assets.js`.
|
|
41
|
+
#
|
|
42
|
+
# @api private
|
|
43
|
+
module AssetsCommand
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def targets
|
|
47
|
+
slices_with_assets.map { Target.for(_1) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def build(target, **)
|
|
51
|
+
result = target.compiler(**).call
|
|
52
|
+
pins = result.importmap&.fetch("pins")&.length
|
|
53
|
+
map_note = pins ? " and import map (#{count(pins, "pin")})" : ""
|
|
54
|
+
stdout.puts "[#{target.name}] Built #{count(result.manifest.length, "asset")}#{map_note} " \
|
|
55
|
+
"into #{target.destination.relative_path_from(target.slice.app.root)}/"
|
|
56
|
+
true
|
|
57
|
+
rescue Importmap::Error => exception
|
|
58
|
+
stderr.puts "[#{target.name}] #{exception.message}"
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def count(number, noun) = "#{number} #{noun}#{"s" unless number == 1}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# `hanami assets compile`, without Node.
|
|
66
|
+
#
|
|
67
|
+
# @api private
|
|
68
|
+
class Compile < Hanami::CLI::Commands::App::Assets::Compile
|
|
69
|
+
include AssetsCommand
|
|
70
|
+
|
|
71
|
+
desc "Compile assets for deployments (hanami-importmap)"
|
|
72
|
+
|
|
73
|
+
def call(**)
|
|
74
|
+
all = targets
|
|
75
|
+
return stdout.puts("No assets found.") if all.empty?
|
|
76
|
+
|
|
77
|
+
results = all.map { build(_1, fingerprint: true, sri: config.subresource_integrity) }
|
|
78
|
+
raise Hanami::CLI::Error, "Assets compilation failed" unless results.all?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# `hanami assets watch`, without Node.
|
|
83
|
+
#
|
|
84
|
+
# Builds with stable filenames, then rebuilds a slice whenever a file under its assets
|
|
85
|
+
# directory or its `config/importmap.rb` changes. Build errors are printed and watching
|
|
86
|
+
# continues.
|
|
87
|
+
#
|
|
88
|
+
# @api private
|
|
89
|
+
class Watch < Hanami::CLI::Commands::App::Assets::Watch
|
|
90
|
+
include AssetsCommand
|
|
91
|
+
|
|
92
|
+
desc "Start assets watch mode (hanami-importmap)"
|
|
93
|
+
|
|
94
|
+
def call(**)
|
|
95
|
+
slices = slices_with_assets
|
|
96
|
+
return stdout.puts("No assets found.") if slices.empty?
|
|
97
|
+
|
|
98
|
+
# Stream output line by line, including when run under `hanami dev` without a TTY.
|
|
99
|
+
stdout.sync = true if stdout.respond_to?(:sync=)
|
|
100
|
+
|
|
101
|
+
slices.each { build(Target.for(_1), fingerprint: false) }
|
|
102
|
+
|
|
103
|
+
require "listen"
|
|
104
|
+
listeners = [
|
|
105
|
+
listen(slices.map { Target.for(_1).source }) { |paths| rebuild(slices, paths) },
|
|
106
|
+
listen(config_dirs(slices), only: /\Aimportmap\.rb\z/) { |paths| rebuild(slices, paths) }
|
|
107
|
+
].compact
|
|
108
|
+
listeners.each(&:start)
|
|
109
|
+
stdout.puts "Watching for asset changes..."
|
|
110
|
+
|
|
111
|
+
wait_for_interrupt
|
|
112
|
+
listeners.each(&:stop)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def listen(dirs, **options, &block)
|
|
118
|
+
dirs = dirs.select(&:directory?).map { File.realpath(_1) }.uniq
|
|
119
|
+
return if dirs.empty?
|
|
120
|
+
|
|
121
|
+
Listen.to(*dirs, **options) do |modified, added, removed|
|
|
122
|
+
block.call((modified + added + removed).uniq)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def config_dirs(slices)
|
|
127
|
+
slices.flat_map { [_1.root.join("config"), _1.app.root.join("config")] }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def rebuild(slices, paths)
|
|
131
|
+
slices.each do |slice|
|
|
132
|
+
target = Target.for(slice) # re-resolved, since a slice may gain or lose its own import map
|
|
133
|
+
source = target.source.directory? ? File.realpath(target.source) : target.source.to_s
|
|
134
|
+
map = target.importmap_path && File.realpath(target.importmap_path)
|
|
135
|
+
|
|
136
|
+
next unless paths.any? { _1.start_with?("#{source}/") || _1 == map || File.basename(_1) == "importmap.rb" }
|
|
137
|
+
|
|
138
|
+
build(target, fingerprint: false)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def wait_for_interrupt
|
|
143
|
+
queue = Thread::Queue.new
|
|
144
|
+
%w[INT TERM].each { |signal| Signal.trap(signal) { queue << signal } }
|
|
145
|
+
queue.pop
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "hanami/cli"
|
|
5
|
+
require_relative "install"
|
|
6
|
+
|
|
7
|
+
module Hanami
|
|
8
|
+
module Importmap
|
|
9
|
+
module CLI
|
|
10
|
+
# Callbacks on hanami-cli's own commands, so import maps are set up without extra steps.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
module Hooks
|
|
14
|
+
# After `hanami install`: switch the app to import maps the first time it runs with this gem
|
|
15
|
+
# bundled, so adding the gem and running `hanami install` is enough.
|
|
16
|
+
class AfterInstall < Hanami::CLI::Command
|
|
17
|
+
def call(*, **)
|
|
18
|
+
return if Pathname("config/importmap.rb").exist?
|
|
19
|
+
|
|
20
|
+
Install.new(stdout:, stderr:, fs:).call
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# After `hanami generate slice`: give the new slice its own import map, in place of the
|
|
25
|
+
# esbuild-style `javascript_tag` and CSS import the slice generator writes.
|
|
26
|
+
class AfterGenerateSlice < Hanami::CLI::Command
|
|
27
|
+
def call(name:, **)
|
|
28
|
+
slice = Dry::Inflector.new.underscore(name)
|
|
29
|
+
return unless Pathname("config/importmap.rb").exist?
|
|
30
|
+
return unless Pathname("slices").join(slice, "assets").directory?
|
|
31
|
+
return if Pathname("slices").join(slice, "config", "importmap.rb").exist?
|
|
32
|
+
|
|
33
|
+
Install.new(stdout:, stderr:, fs:).call(slice:)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "hanami/cli"
|
|
5
|
+
require_relative "../packager"
|
|
6
|
+
require_relative "../resolver"
|
|
7
|
+
require_relative "assets"
|
|
8
|
+
|
|
9
|
+
module Hanami
|
|
10
|
+
module Importmap
|
|
11
|
+
module CLI
|
|
12
|
+
# Paths for the app or a slice, without booting the app.
|
|
13
|
+
#
|
|
14
|
+
# @api private
|
|
15
|
+
module SlicePaths
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def slice_root(slice)
|
|
19
|
+
return Pathname(".") unless slice
|
|
20
|
+
|
|
21
|
+
# A plain inflector, so these commands don't boot the app.
|
|
22
|
+
root = Pathname("slices").join(Dry::Inflector.new.underscore(slice))
|
|
23
|
+
raise Hanami::CLI::MissingSliceError.new(slice) unless root.directory?
|
|
24
|
+
|
|
25
|
+
root
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def assets_dir(slice)
|
|
29
|
+
slice ? slice_root(slice).join("assets") : Pathname("app/assets")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def importmap_path(slice)
|
|
33
|
+
slice_root(slice).join("config", "importmap.rb")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def packager(slice)
|
|
37
|
+
Packager.new(importmap_path: importmap_path(slice), vendor_path: assets_dir(slice).join("vendor"))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# `hanami importmap pin`
|
|
42
|
+
#
|
|
43
|
+
# @api private
|
|
44
|
+
class Pin < Hanami::CLI::Commands::App::Command
|
|
45
|
+
include SlicePaths
|
|
46
|
+
|
|
47
|
+
desc "Pin npm packages, downloading them into the assets vendor directory"
|
|
48
|
+
|
|
49
|
+
argument :packages, type: :array, required: true, desc: "Package names, optionally with versions (e.g. lodash@4.17.21)"
|
|
50
|
+
option :slice, required: false, desc: "Slice name"
|
|
51
|
+
option :from, required: false, default: "jspm", desc: "CDN to resolve packages with (jspm, jsdelivr, unpkg)"
|
|
52
|
+
option :preload, type: :flag, default: true, desc: "Preload the pinned modules"
|
|
53
|
+
|
|
54
|
+
example [
|
|
55
|
+
"@hotwired/stimulus # Pin Stimulus for the app",
|
|
56
|
+
"@hotwired/turbo --slice=admin # Pin Turbo for the admin slice",
|
|
57
|
+
"lodash@4.17.21 --no-preload # Pin a specific version, without preloading"
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
def call(packages:, slice: nil, from: "jspm", preload: true, **)
|
|
61
|
+
packager = packager(slice)
|
|
62
|
+
imports = packager.import(*packages, from:)
|
|
63
|
+
|
|
64
|
+
unless imports
|
|
65
|
+
raise Hanami::CLI::Error, "Couldn't find #{packages.join(", ")} on #{from}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
create_importmap(slice) unless packager.importmap_path.file?
|
|
69
|
+
|
|
70
|
+
imports.each do |package, url|
|
|
71
|
+
path = packager.vendored_path(package)
|
|
72
|
+
stdout.puts %(Pinning "#{package}" to #{path} via download from #{url})
|
|
73
|
+
packager.download(package, url)
|
|
74
|
+
packager.pin(package, url, preload:)
|
|
75
|
+
end
|
|
76
|
+
rescue Importmap::Error => exception
|
|
77
|
+
raise Hanami::CLI::Error, exception.message
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# A slice's own import map replaces the app's, so start it with the slice's entry point.
|
|
83
|
+
def create_importmap(slice)
|
|
84
|
+
path = importmap_path(slice)
|
|
85
|
+
entry = assets_dir(slice).join("js", "app.js").file? ? %(\npin "app"\n) : ""
|
|
86
|
+
fs.create(path.to_s, Packager::IMPORTMAP_HEADER + entry)
|
|
87
|
+
|
|
88
|
+
if slice && Pathname("config/importmap.rb").file?
|
|
89
|
+
stdout.puts "The #{slice} slice now uses #{path} instead of config/importmap.rb."
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# `hanami importmap unpin`
|
|
95
|
+
#
|
|
96
|
+
# @api private
|
|
97
|
+
class Unpin < Hanami::CLI::Commands::App::Command
|
|
98
|
+
include SlicePaths
|
|
99
|
+
|
|
100
|
+
desc "Unpin packages and delete their vendored files"
|
|
101
|
+
|
|
102
|
+
argument :packages, type: :array, required: true, desc: "Package names"
|
|
103
|
+
option :slice, required: false, desc: "Slice name"
|
|
104
|
+
|
|
105
|
+
def call(packages:, slice: nil, **)
|
|
106
|
+
packager = packager(slice)
|
|
107
|
+
|
|
108
|
+
packages.each do |package|
|
|
109
|
+
if packager.remove(package)
|
|
110
|
+
stdout.puts %(Unpinned and removed "#{package}")
|
|
111
|
+
else
|
|
112
|
+
stderr.puts %("#{package}" isn't pinned in #{packager.importmap_path})
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# `hanami importmap json`
|
|
119
|
+
#
|
|
120
|
+
# @api private
|
|
121
|
+
class Json < Hanami::CLI::Commands::App::Command
|
|
122
|
+
desc "Print the import map, as built by the last `hanami assets compile` or `watch`"
|
|
123
|
+
|
|
124
|
+
option :slice, required: false, desc: "Slice name"
|
|
125
|
+
|
|
126
|
+
def call(slice: nil, **)
|
|
127
|
+
target_slice = slice ? app.slices[inflector.underscore(slice).to_sym] : app
|
|
128
|
+
target = Target.for(target_slice)
|
|
129
|
+
assets = Hanami::Assets.new(config: target_slice.config.assets, root: target.destination)
|
|
130
|
+
map = Resolver.new(assets, target.destination).call
|
|
131
|
+
|
|
132
|
+
stdout.puts JSON.pretty_generate(map.to_h.merge("preload" => map.preloads))
|
|
133
|
+
rescue Importmap::Error => exception
|
|
134
|
+
raise Hanami::CLI::Error, exception.message
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|