hanami-importmap
Import maps for Hanami. Ship JavaScript as ES modules, with no Node, no npm, and no bundler. This is the Hanami counterpart to importmap-rails.
# config/importmap.rb
pin "app"
pin "@hotwired/turbo", to: "@hotwired--turbo.js"
pin "@hotwired/stimulus", to: "@hotwired--stimulus.js"
pin_all_from "js/controllers", under: "controllers"<%# app/templates/layouts/app.html.erb %>
<head>
<%= stylesheet_tag "app" %>
<%= javascript_importmap_tags %>
</head>How it fits into Hanami
hanami-importmap is a bundler for hanami-assets, written in Ruby. It does not replace
hanami-assets. It replaces hanami assets compile and hanami assets watch, and writes the same
public/assets/**/assets.json manifests that the esbuild bundler writes, following the
hanami-assets bundler spec.
Everything that reads those manifests keeps working as before:
-
javascript_tag,stylesheet_tag,image_tag,favicon_tagandasset_url -
config.assets.base_url(CDNs) andconfig.assets.subresource_integrity - static file serving
- slices, which each get their own assets and import map
bin/dev
Installation
Requires Hanami 2.3+ with hanami-assets. It is built and tested against Hanami's main branches.
Add the gem to the :cli group as well as the default group. The hanami CLI only loads the
:cli group, and that's how the gem takes over the assets commands.
gem "hanami-importmap", group: [:default, :cli]Then switch the app over:
bundle install
bundle exec hanami install # or `bundle exec hanami importmap install`For a new app, run hanami new bookshelf, add the gem, and run those two commands. hanami install
switches the app to import maps the first time it runs with the gem bundled, when there's no
config/importmap.rb yet; later runs leave the app alone. hanami new has no option for this,
and a gem can't add one, because Hanami loads plugins from the app's Gemfile and the app doesn't
exist yet.
install makes these changes:
-
config/app.rb- Requires
hanami/importmap. - Adds
'nonce'to the Content Security Policy'sscript-src(see below).
- Requires
-
config/importmap.rb: creates it, withpin "app". -
app/assets/js/app.js: removesimport "../css/app.css". That import only works with esbuild; the layout already loads the CSS withstylesheet_tag. -
The layout: swaps
javascript_tag "app"forjavascript_importmap_tagsin the<head>. -
Node files:
- Removes
config/assets.js,package.json,package-lock.json, andnpm installfrombin/setup. - Files you've customized are kept, and the command says so.
- Delete
node_modules/yourself.
- Removes
hanami generate slice gets the same treatment automatically: the new slice gets its own
config/importmap.rb and javascript_importmap_tags. For a slice that existed before you added
the gem, run bundle exec hanami importmap install --slice=admin.
Pinning packages
bundle exec hanami importmap pin @hotwired/stimulus @hotwired/turbo
bundle exec hanami importmap pin canvas-confetti --slice=admin
bundle exec hanami importmap pin lodash@4.17.21 --no-preload
bundle exec hanami importmap unpin lodashpin resolves packages and their dependencies with the jspm.io generator. It
downloads them into app/assets/vendor/ (or slices/<name>/assets/vendor/) and adds the pin
lines. Vendored files are your assets: they're fingerprinted, served from your own origin, and
committed to your repo.
bundle exec hanami importmap json [--slice=admin] prints the import map from the last build.
The DSL
The to: value of a pin is an asset name, the same name you'd give to javascript_tag. Asset
names are paths under the assets directory, with the first directory removed:
| File | Asset name |
|---|---|
app/assets/js/app.js |
app.js |
app/assets/js/controllers/hello_controller.js |
controllers/hello_controller.js |
app/assets/vendor/@hotwired--turbo.js |
@hotwired--turbo.js |
app/assets/images/logo.png |
logo.png |
pin "app" # to: "app.js"
pin "@hotwired/turbo", to: "@hotwired--turbo.js"
pin "lodash", to: "https://ga.jspm.io/npm:lodash@4.17.21/lodash.js", preload: false,
integrity: "sha384-…" # a URL, not vendored
pin_all_from "js/controllers", under: "controllers"
# js/controllers/hello_controller.js → "controllers/hello_controller"
# js/controllers/index.js → "controllers"Pins are preloaded (<link rel="modulepreload">) unless you pass preload: false.
If a pin doesn't match an asset, hanami assets compile fails and names the pin. You won't find
out in the browser.
Slices
- A slice with its own
slices/<name>/config/importmap.rbgets its own map, built from its own assets. - A slice without one uses the app's
config/importmap.rb, resolved against the slice's assets. This is the same fallback hanami-assets uses forconfig/assets.js. -
javascript_importmap_tagsalways renders the current slice's map.
What compile and watch do
-
hanami assets compile- Copies every file under each assets directory to
public/assets/, with the content digest in the filename:app-F5D98970.js. - Rewrites CSS
url(...)and@importreferences to other assets to point at their output paths. - Writes
assets.json, plusimportmap.jsonfrom yourconfig/importmap.rb. - Includes
sridigests whenconfig.assets.subresource_integrityis set. These go into the import map'sintegritysection too, so the browser checks every module, not just the entry point.
- Copies every file under each assets directory to
-
hanami assets watch- Builds with stable filenames.
- Rebuilds a slice when a file in its assets directory or its
importmap.rbchanges. - The running server picks up new maps without a restart.
There's no transpiling and no minification. TypeScript, JSX and Sass files raise an error that points you at hanami-assets-js.
Content Security Policy
Hanami's default policy is script-src 'self', which blocks inline scripts. Browsers don't support
external import maps, so the map has to be an inline <script type="importmap">.
install adds 'nonce' to script-src. Hanami then adds its nonce middleware, and
javascript_importmap_tags puts the request's nonce on its tags:
config.actions.content_security_policy[:script_src] += " 'nonce'"Pass nonce: false to leave it off, or nonce: "…" to set it yourself.
Caveats
-
Use bare specifiers between your own modules. For example, write
import "controllers/hello_controller", notimport "./hello_controller.js". Fingerprinted filenames break relative imports. importmap-rails has the same rule, andpin_all_fromexists for this. - Pick one bundler per app. The override covers the whole app, so you can't use esbuild for one slice and import maps for another.
Development
bundle install
bundle exec rspecThe specs include an end-to-end run of the real hanami CLI in a temporary app. See
hanami-importmap-example for a full app.
License
MIT