How to use importmaps in a gem

Without going in too much details for what I’ve already tried (believe me, that’s a lot), how can I achieve the following?

Create a gem - suitable for a RubyOnRails 7.1 application - not using code compilers, that uses multiple javascripts to organize code, but produces one javascript output file that can be included by the application? The application uses the importmap-rails gem and I guess the gem should do the same.

I want to create something like a my_gem.js that contains:

import 'src/a';
import 'src/b';
import 'src/c';

I think - ideally - I want to use a line like pin mygem inside the application’s config/importmap.rb file and in one of its Javascripts, use import 'mygem';. If necessary - I might need to add my_gem.js inside config/initializers/assets.rb.

Important: I don’t want to use NodeJS, Yarn, ESBuild to compile the output file! I want as little dependencies as possible.

1 Like

This is a fascinating idea. I admit I don’t know very much of anything about import maps, but I think that the key to finding these files is going to lie in getting them into the asset pipeline, and then constructing your import calls to use relative paths (./, …/…/, that sort of thing) to resolve their location. I distantly recall there is some strong magic around a leading dot in an import path in these cases…

And I stand ready to be educated by someone who actually uses this feature regularly.

Walter

Again, I spent hours trying to find the right settings, but it seems impossible. Please, could someone explain why import maps are easier than the way things worked in Rails 4.2 with Sprockets, where I could simply refresh my browser and it worked? I want to use modern Rails architecture, but I’ve been struggling for more than a year (!) now and haven’t found the right way of using Javascript in a simple and concise manner in Rails 7.

I will give some insights of what I have right now: suppose the gem is called MyGem and it is placed in a directory my_gem. I have these crucial files:

# my_gem.gemspec
Gem::Specification.new do |spec|
  # here are the default spec settings, like authors, files, etc.

  spec.add_dependency('importmap-rails')
  spec.add_dependency('railties')
end

# my_gem/lib/my_gem.rb
require('my_gem/engine')

# my_gem/lib/my_gem/engine.rb
module MyGem
  class Engine < ::Rails::Engine
    initializer('my_gem.importmap', before: 'importmap') do |app|
      app.config.importmap.paths << root.join('config/importmap.rb')
    end
  end
end

Then, for the “import maps” part:

# my_gem/config/importmap.rb
pin_all_from(File.expand_path('../app/javascript/src', __dir__), under: 'src')

# Source files that I want to include (these are fake names):
# my_gem/app/javascript/src/controllers.js
# my_gem/app/javascript/src/helpers.js
# my_gem/app/javascript/src/custom.js

# The actual file that I want to be accessible from the Rails application:
# my_gem/app/javascript/my_gem.js
import 'src/controllers';
import 'src/helpers';
import 'src/custom';

alert('Hello world!');

No clue if this is correct from the gem’s point-of-view and no clue of how to access my_gem.js from the application. I would think something like this:

# config/importmap.rb
pin('my_gem')

# app/javascript/application.js
import 'my_gem';

Any help would be highly appreciated.

1 Like

I’ve had contact with Walter about this problem, we exchanged notes and by combining his attempts and what is on stack overflow, I’ve found a solution!

I made a guide for it here: Import JS files from a Gem, using importmap-rails (eirvandelden.com)

@wallytax I know you want to export into a single JS file; that is not something either Propshaft or importmap-rails will do for your, you need a build step for that.

1 Like

@eirvandelden you did a great job in finding the right combination of things and I managed to get it working as well, but…

Let me first answer your comment. I don’t mind the browser loading multiple files, as long as I don’t need to include multiple files from the application. So the gem offers one javascript that may import multiple files (in the example above I imported src/controllers, src/helpers and src/custom).

But after building all this, I found out that the whole importmap is used for all layouts that I have in my application, which is 100% not what I want.

I have one application - sharing lots of Ruby code - offering a webshop and employee portal. The webshop uses Stimulus and Turbo Frames and is an “old school” HTML site… on steroids. :slight_smile: The employee portal is a single page javascript application showing incoming orders, customers, and so on. I don’t want the HTML of the webshop to give any clues about files being used in the employee portal.

Searching for a solution on that gives me the idea that importmaps isn’t production worthy yet, at least not for me. Too bad, because it has costed me extremely much time to try to get it to work.

Any input however is still highly appreciated, I don’t like giving up, but it seems like the only thing to do right now.

I’m now at the point of simply putting the 3 individual parts of my javascript application (from the example above) and put it in the public folder of the gem. That public folder is added to Rails’ middleware in engine.rb:

initializer('my_gem.static_assets') do |app|
  app.middleware.insert_before(::ActionDispatch::Static, ::ActionDispatch::Static, "#{root}/public")
end

Loading files like this, is that inefficient with HTTP2? Again, I would like to adopt new standards, but for me there’s clearly no such standard and I believe the process is way too complicated.

Loading files like this, is that inefficient with HTTP2?

Nope, offering JS in separate files is exactly the point for using Importmaps and HTTP/2. DHH had a tweet about performance, but I can’t find it anymore, this is the best I could find: https://twitter.com/dhh/status/1712145950397841826

But after building all this, I found out that the whole importmap is used for all layouts that I have in my application, which is 100% not what I want.

Read Alexs’ post again, in your case you should use multiple entry points. He explains it after this line:

Alternatively, if you want to split up your bundle, you can use a separate module tag in your layout:

If it’s no penalty cost to load them in multiple <SCRIPT> tags, then that’s a valid option.

I’ve read the part of the separate module tags, but I couldn’t get it to work. From my experience this listens very closely, so I need to have exact lines of configuration to get this to work. His information was not enough for me. I got all kinds of errors when trying to do them and don’t know where to put which line in which importmap file.

To follow up/end this discussion, I want to explain what I finally achieved and for now that’s a fine solution. First of all, I’ve dropped the whole importmaps idea. To me, it seems too immature at the moment. The most important blocking issue I faced - after implementing @eirvandelden’s solution - is that I can’t (easily) hide the importmaps definition in a HTML template used for a webshop containing information of files being used in an employee portal (which is a single page JavaScript app).

The gem I now have, is very lean. It contains a file/directory structure like this:

Root Sub 1 Sub 2 Sub 3
app
javascript
my_gem
file1.js
file2.js
file3.js
lib
my_gem
engine.rb
my_gem.rb
index.js
my_gem.gemspec
package.json

The index.js file contains these lines:

import 'app/javascript/my_gem/file1';
import 'app/javascript/my_gem/file2';
import 'app/javascript/my_gem/file3';

Nothing special needs to be done in the lib/my_gem.rb and lib/my_gem/engine.rb files!

The crucial part is to create a package.json file so that the gem basically becomes a Node package as well. Not sure if this is common practice, but I like it. A hybrid solution!

Make sure the package.json file contains the required parts. Something like this:

{
  "author": "...",
  "description": "...",
  "name": "my_gem",
  "repository": "...",
  "version": "1.0.0"
}

The gem is ready to be used in an application! That application should at least include the jsbundling-rails and propshaft gems (not importmaps-rails and sprocket-rails). Besides that, it needs a package.json file, containing my_gem and esbuild as dependencies. You don’t need to register your Ruby/NodeJS hybrid to rubygems.org and/or npmjs.org: both Gemfile and package.json can download from a GIT-repository!

Then, create app\javascript\application.js with the line import 'my_gem'; at the top and you’re good to go!

As a bonus I’ve set up a watcher in Procfile.dev that compiles that application.js JavaScript:

js: yarn build --watch
web: bin/rails server -p 3000

This works, because package.json also contains:

"scripts": {
  "build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds"
}

As a final bonus, I’ve managed to trick the watcher to notice changes in my gem as well. I use this during development of the gem. After yarn install or yarn upgrade, the my_gem directory is added to the node_modules directory. By replacing that directory with a symbolic link to the local gem directory, Yarn is able to notice file differences!

$ cd node_modules
$ rm -r my_gem
$ ln -s /path/to/my_gem

I had this set-up quite some time ago, but I used to build the JavaScript inside the gem to create a single output file. That is no longer needed, so the gem no longer needs a dependency of esbuild and NodeJS is not needed for the gem. Still for the application (unfortunately), but for me this is the best solution at the moment!

As a side node to the above, I want to say I make use of the bootstrap gem and since that took quite some time to configure correctly, I want to give you a direction on how to use it in JavaScript and CSS files.

// Example: Show Bootstrap Modal via a Stimulus controller
import { Controller } from '@hotwired/stimulus';
import { Modal } from 'bootstrap';

export default class extends Controller {
  connect() {
    this.modal = new Modal(this.element);
    this.modal.show();
  }
}
// Use full Bootstrap styling in SASS
@import 'bootstrap/scss/bootstrap';

// Use partial Bootstrap in SASS (saves output size)
@import 'bootstrap/scss/bootstrap-utilities';
@import 'bootstrap/scss/bootstrap-reboot';
@import 'bootstrap/scss/buttons';

Hope this is helpful to somebody. It took me quite some time to figure this out!