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