Skip to content

About

Import maps for Hanami: ship ES modules without Node or a bundler

Resources

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

4 Commits

Folders and files

Repository files navigation

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_tag and asset_url
  • config.assets.base_url (CDNs) and config.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's script-src (see below).
  • config/importmap.rb: creates it, with pin "app".
  • app/assets/js/app.js: removes import "../css/app.css". That import only works with esbuild; the layout already loads the CSS with stylesheet_tag.
  • The layout: swaps javascript_tag "app" for javascript_importmap_tags in the <head>.
  • Node files:
    • Removes config/assets.js, package.json, package-lock.json, and npm install from bin/setup.
    • Files you've customized are kept, and the command says so.
    • Delete node_modules/ yourself.

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 lodash

pin 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.rb gets 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 for config/assets.js.
  • javascript_importmap_tags always 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 @import references to other assets to point at their output paths.
    • Writes assets.json, plus importmap.json from your config/importmap.rb.
    • Includes sri digests when config.assets.subresource_integrity is set. These go into the import map's integrity section too, so the browser checks every module, not just the entry point.
  • hanami assets watch
    • Builds with stable filenames.
    • Rebuilds a slice when a file in its assets directory or its importmap.rb changes.
    • 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", not import "./hello_controller.js". Fingerprinted filenames break relative imports. importmap-rails has the same rule, and pin_all_from exists 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 rspec

The 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

About

Import maps for Hanami: ship ES modules without Node or a bundler

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages