Rails engines are one of my favorite tools when I want to isolate reusable functionality for Rails applications. An example of this is the NoPassword gem, which allows users to login into an application just with their email and the received link and code.
With Rails 3.1, the engines were mountable and integrated with the Asset Pipeline to manage the engine’s assets. This continued to work this way until Webpack was introduced to Rails in version 5.1. At this moment, it was not clear how to make Webpack work with the engine’s assets or any other gem that included assets.
Rails 7 introduced CSS and JS bundling, along with the Propshaft gem to manage and serve assets. Also, introduced Importmaps and TailwindCSS as options for not having Node as a dependency, but in both cases without any official word on how to make these work with engines.
NOTE: Importmap’s README explains how to composite multiple Impormap configurations. https://github.com/rails/importmap-rails#composing-import-maps
Working on the NoPassword engine and a few private others, I decided for them to use Importmaps, TailwindCSS, and Propshaft. It led me to figure out a way to use the engine assets. The rest of this post describes my successful journey with assets and engines.
Importmap engine configuration
First, let’s try the route described in Importmap’s README file.
To install Importmap in an engine project, it requires adding the gem to the dummy app and adding the dependency to the engine’s gem spec file.
Install Importmap and add it as a dependency to the Gemfile as follows:
bundle add importmap-rails
And add it to the gem spec file.
spec.add_dependency "importmap-rails", "~> 1.2", ">= 1.2.1"
Then run the bundle command and navigate to the test/dummy app to execute the installer in the dummy app.
cd test/dummy && bin/rails importmap::install
Following the README instructions for composite Importmaps, open the
lib/my_engine/engine.rb file and add the following hook - remember to replace ******my-engine****** with the name of your engine -:
module MyEngine class Engine < ::Rails::Engine # ... initializer "my-engine.importmap", before: "importmap" do |app| app.config.importmap.paths << Engine.root.join("config/importmap.rb") # ... end end end
Then create the file
To test this setup, two Stimulus controllers were added, one in the engine’s
Set up Stimulus in the dummy app by adding the gem and running the installer.
cd test/dummy bundle add stimulus-rails ./bin/rails stimulus:install
The Dummy app has a HomeController with an index action. The index template has two divs. One for
host_controller.js and the second one for
<h1>Engine's Stimulus controller (Host app)</h1> <div data-controller="host"></div> <div data-controller="engine"></div>
Unfortunately, this setup does not work, at least for my use case, where I expect the engine to have Stimulus controllers available in the host application, the Dummy app in this case. The engine’s controller is there in the imports manifest but is not loaded in the context of the Stimulus application.
The way that I make this work is to first organize the engine’s Stimulus controllers with the following directory structure:
Next, modify the Dummy app’s
config/importmap.rb and add the following line at the end of the file.
With this change, reload the page, and now it is working! The engine’s Stimulus controller is loaded by the host application.
In the Importmap repository, there is an open issue about how to make the gem work with engines, and the user muriloime mentions this solution https://github.com/rails/importmap-rails/issues/58
Now, what about making Importmap work with the engine’s own views? In the case of the NoPassword gem, it provides views for the login process and uses a Stimulus controller to display errors; it does not depend on the host application in any way.
First, add Stimulus as a gem dependency to the gem spec file and execute the bundle command.
spec.add_dependency "stimulus-rails", "~> 1.2"
Open the file
config/importmap.rb and replace the content with the following pins that load Stimulus and the engine’s controllers:
Instead of composing a global Importmap, we are going to set up a local configuration for the engine. Open the
lib/my_engine.rb file and add the following code:
require "my_engine/version" require "my_engine/engine" require "importmap-rails" module MyEngine class << self attr_accessor :configuration end class Configuration attr_reader :importmap def initialize @importmap = Importmap::Map.new @importmap.draw(Engine.root.join("config/importmap.rb")) end end def self.init_config self.configuration ||= Configuration.new end def self.configure init_config yield(configuration) end end MyEngine.init_config
Here, a configuration class was added to the engine. It has only one setting, where it initializes a new Importmap with assets belonging to the engine. Be aware that sweepers are not set up for Importmap; this means that changes made during development with the engine will not be taken until the app is restarted. This configuration can be extended to include more engine settings if needed.
Now we need to create a helper similar to
app/helpers/my_engine/application_helper.rb and add the following method.
Now open the engine’s layout and replace the
To test this, add a controller named
MyEngine::HomeController with an index action and a div that uses
engine_controller.js to it.
These are the two configuration options for Importmap with engines. Share the engine’s Stimulus controllers with the host app, or use the engine’s Stimulus controllers internally for the engine’s templates.
TailwindCSS engine configuration
The steps to install it are simple. First, let’s install it into the Dummy app.
cd test/dummy && bundle add tailwindcss-rails ./bin/rails tailwindcss:install
For the Dummy app that comes with the engine in development mode, there are two additional steps that are not required when the host app is not the Dummy app.
Ensure that the TailwindCSS task runs with Foreman or Overmind using your
web: bin/rails server -p 3000 css: bin/rails app:tailwindcss:watch
Also, change the
test/dummy/config/tailwind.config.js file that was installed by TailwindCSS to have the right path to the Dummy app. Change the content section to include the
Restart your application, and it should work for the Dummy app.
To extend the TailwindCSS styling into the engine’s templates, there are a few steps that need to be taken first. The host app, in our case, the Dummy app, needs to override the engine’s layout to include the TailwindCSS files. Copy the file
test/dummy/app/layouts/my_engine/application.html.erb and add the following line in the head section:
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
Navigate to an engine action, and you can confirm the general CSS reset of TailwindCSS is applied, but utility classes are ignored.
This happens because the file doesn’t know that it also needs to scan the engine templates for TailwindCSS classes.
To fix this, we need a rake task and a generator in the host or Dummy app. Create a file
test/dummy/lib/tasks/tailwind.rake and add the following content:
# frozen_string_literal: true namespace :tailwindcss do desc "Generates your tailwind config file" task :config do Rails::Generators.invoke("tailwind_config", ["--force"]) end end Rake::Task["tailwindcss:build"].enhance(["tailwindcss:config"]) Rake::Task["tailwindcss:watch"].enhance(["tailwindcss:config"])
Here we are adding a tailwindcss:config task that invokes a generator (we are going to create this one in a moment) and enhancing TailwindCSS tasks provided by the gem by injecting this new task into the build process.
For the generator, create the file
test/dummy/lib/generators/tailwind_config_generator.rb in the host or Dummy app and add the following code:
# frozen_string_literal: true class TailwindConfigGenerator < Rails::Generators::Base source_root File.expand_path("../templates", __FILE__) def create_tailwind_config_file @engines_paths = MyEngine.configuration.tailwind_content # The second parameter for the template method is required only if the host app is the Dummy app; # for an external host app, remove that parameter. template "config/tailwind.config.js", File.expand_path("../../../config/tailwind.config.js", __FILE__) end end
This generator, when executed, creates a new TailwindCSS config file from a template, but before doing that, in the
create_tailwind_config_file method, we can set up the engine paths to consider when scanning for TailwindCSS classes.
In the code, the generator assumes that our engine configuration exposes a
tailwind_content accessor with an array of paths.
Add an accessor to the Configuration class in file
Also add to the
initializer method of the same class the following paths:
Moving back to the template file in the
tailwind_config_generator, this is the TailwindCSS config file, but it will be created dynamically by the task to be able to inject the engine paths into it.
Move the file
lib/generators/templates/config/tailwind.config.js.tt and modify the content section as follows:
Add the file
.gitignore file, since this file will be generated automatically.
Restart the application, navigate the view in the host app and a view in the engine, and confirm that TailwindCSS is working for both cases.
This is how I have been working with the Rails engines Importmap and TailwindCSS. Most of the knowledge here came from reading the source code of the libraries and trial and error.
If you are looking to work with engines and this tool set for assets, please give it a try and let me know how it goes or if there is a better, simpler way to archive the same.