Importing images in JS files with esbuild and Rails

Liubomyr Manastyretskyi
Oct 4, 2022 • 7 minutes read

With the release of rails 7 and deprecation of webpacker gem rails developers found themselves in some sort of a new, unexplored environment with the "new way" of building frontend in rails app using either importmaps or jsbundling, and people started to adopt these new technologies, and learn the hard way how build an app using these new technologies.

Recently I had to build an app that required its pages to be highly dynamic, with lots of charts. The natural choice was to build these parts in ReactJS, so using importmaps was not an option, then I turned to jsbundling-rails and esbuild specifically, as it the most popular option. I also use propshft instead of sprockets. After some time of development, I needed to add an image, and the natural way to do it in react, is to import it and then use it as an image source.

Here is a simple component that will be used for demonstration (I will not show how it is mounted on the page as it is not that important, you can see a guide here)

import React from "react";
import Logo from "./Logo.png";

const App = () => {
  return (
    <div>
      <img src={Logo} width="100px" />
    </div>
  );
};

export default App;

But if you will try to build an app now you will see error like this:

[ERROR] No loader is configured for ".png" files: app/javascript/Logo.png

This is because in order to be able to import png files in js files you need to add loader for each extension to esbuild configs. Esbuild allows you to import any file in your js file as long as you add a loader for its extension, in this example it will be a png file. By default jsbundling-rails install script will add esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=assets as a build task to your package.json, but it will get hard to manage everything with params inline, so I used configuration file instead, I created esbuild.config.js in the root folder of the project and changed build command to this - node esbuild.config.js. Here is the final file that I have in the end.

const path = require('path')

require("esbuild").build({
  entryPoints: ["application.js"],
  bundle: true,
  outdir: path.join(process.cwd(), "app/assets/builds"),
  absWorkingDir: path.join(process.cwd(), "app/javascript"),
  watch: process.argv.includes("--watch"),
  loader: {
    '.js': 'jsx',
    '.png': 'file',
    '.svg': 'file'
  },
}).catch(() => process.exit(1))

Now we can build our app, but If we then open a page we will see that image is not found.

Now let's import png file into js and see what output it will produce. For now I removed everything from application.js file and added this content.

import Logo from "./Logo.png"

console.log(Logo);

And here is the result (it can be found in app/assets/builds folder)

(() => {
  // Logo.png
  var Logo_default = "./Logo-HOY4XBYQ.png";

  // application.js
  console.log(Logo_default);
})();

So, we can see that when we import a non-js file it is represented as a string which is basically a path to that file (all files imported in your js files will be put inside app/assets/builds folder with hash in the end of file name, so in this case "./Logo-HOY4XBYQ.png" is a relative path to this image), this would work if we were to put all of our assets in a public folder, but it is just hard to manage, so instead I decided to use a bit different approach.

The idea is simple and was taken from sprockets, propshaft allows you to add custom compilers which we can use in order to change this relative path to file, to a proper rails asset path.

Here is the the compiler I came up with (I put this inside config/initializers/js_asset_urls.rb file)

class JsAssetUrls
  attr_reader :assembly

  ASSET_URL_PATTERN = /\"(.\/((.*).(png|jpeg|svg|jpg)))\";/

  def initialize(assembly)
    @assembly = assembly
  end

  def compile(logical_path, input)
    input.gsub(ASSET_URL_PATTERN) { asset_url resolve_path(logical_path.dirname, $1), $1 }
  end

  private

  def resolve_path(directory, filename)
    if filename.start_with?("../")
      Pathname.new(directory + filename).relative_path_from("").to_s
    elsif filename.start_with?("/")
      filename.delete_prefix("/").to_s
    else
      (directory + filename.delete_prefix("./")).to_s
    end
  end

  def asset_url(resolved_path, pattern)
    if asset = assembly.load_path.find(resolved_path)
      %["#{assembly.config.prefix}/#{asset.digested_path}";]
    else
      Propshaft.logger.warn "Unable to resolve '#{pattern}' for missing asset '#{resolved_path}'"
      %["#{pattern}";]
    end
  end
end

Rails.application.configure do
  config.assets.compilers << ["text/javascript", JsAssetUrls]
end

As you can see, I set it up to compile javascript files, which means that every js file that will be compiled by propshaft will go through "compile" method, basically here we will get path of the file that is being compiled and actual content of that file, the value that we will return from that method will then be written into result file.

In the compile method I gsub every file import with this regexp /\"(.\/((.*).(png|jpeg|svg|jpg)))\";/

Important thing to note here is that depending on your configuration this exact regexp may not work for you, but if you use esbuild configs similar to the one on the top, it should work fine.

In gsub I pass a block, where two things happen, first I get the exact file path to imported image with resolve_path method, and then I get proper rails assets for that image from asset_url, and resulting string is then put instead of an old path to file.

Let's now look at how output from previous example will look like after it is processed by this new compiler

(() => {
  // Logo.png
  var Logo_default = "/assets/Logo-HOY4XBYQ-662b2b7102319d49be99b146d6bce1e85936bd64.png";

  // application.js
  console.log(Logo_default);
})();

You see that it is no longer a relative path, it starts with /assets (folder where all assets will be put after you precompile them) and in has this postfix -662b2b7102319d49be99b146d6bce1e85936bd64 which is added by propshaft as a digest.

Now that everything is done, component from the beginning of this article is compiled with proper path to this image 🎉 🍾 

So that’s how I solved this problem. I was not able to find any other way of importing images in js files with esbuild, rails and propshaft and I think that it is a pretty simple and elegant solution that does not require a lot of changes to build configuration, feel free to contact me via email or by contact form in case you have any additional questions or suggestions.

 

Useful gems

See all