Materialize on Rails 4 with Bower and Heroku

Using third-party frameworks such as Materialize (and e.g. Bootstrap) with Rails can present irksome issues with the Rails asset pipeline. You finally get it working perfectly in development, but when you push to Heroku, it all goes horribly wrong!

There are a number of ways to add a framework such as Materialize to Rails, but with the emergence of Bower, it would be nice to have a reliable model for configuring and distributing the files which works both in development and production without any fudging.

This post hopes to present one way to get Materialize and Rails 4 working in a way which is as consistent as possible with the 'Rails way'.

Step 1. Getting Rails and Bower to play nice

Conveniently, I've written a post on this very subject, so please start there then come back ;o)

If you prefer to use a gem such as bower-rails you can; simply adjust the code that follows where necessary according to the location of your bower_components.

Step 2. Installing Materialize

Now we have Bower installed, installing Materialize is as simple as:

$ bower install --save materialize

This will install all of Materialize's source files in vendor/bower_components/materialize. Exploring this folder, you will notice there are actually four(!) different options for implementing the dependency:

Three for CSS:

  • materialize/bin/materialize.css
  • materialize/dist/css/materialize.css
  • materialize/dist/css/materialize.min.css

and one for Sass:

  • materialize/sass/materialize.scss

To get the most out of Materialize (and indeed to get it to work properly with Rails), you should be using the Sass dependency.

The problem with all of the CSS dependencies is they rely on relative font paths (e.g. ../font/roboto) to load the included fonts (i.e. Roboto). This would be fine if you were serving the CSS files directly from bower_components (since the relative paths would be preserved); but in Rails, Sprockets and the assets pipeline will change all of this and the relative paths simply won't work.

Secondly, if you want to override any of the Materialize defaults (e.g. colours), you really want to be doing this as close to the source as possible using the Materialize Sass variables to ensure your overrides are consistent and universal. Trying to override a Sass or Less framework's defaults in CSS is a nightmare (and kind of defeats the point!)

So what you don't want in your app/assets/stylesheets/application.css file is the following or similar:

*= require materialize/dist/css/materialize.css

Instead, create a file called materialize.scss in app/assets/stylesheets. This will be included automatically in your asset compilation if you have *= require tree in app/assets/stylesheets/application.css.

Alternatively, You could add the code which follows to app/assets/stylesheets/application.scss - notice the file has been subtly renamed to .scss - but using a dedicated file is better.

app/assets/stylesheets/materialize.scss:

@import 'materialize/sass/materialize';

Try running your project to see if the new framework has been applied.

Check the browser console - you should see something like the following:

http://localhost:3000/font/roboto/Roboto-Regular.ttf 404 (Not Found)  

The problem is Materialize, by default, uses the path ../font/roboto/ for the Roboto font, which resolves to a path not provided by your Rails app. Fortunately, Materialize uses overridable variables to define this path. The Roboto font is defined in vendor/bower_components/materialize/sass/components/_roboto.scss and you can see here it uses a Sass variable $roboto-font-path to comprise the path. The variable is definied in vendor/bower_components/materialize/sass/components/_variables.scss and is easily overridden by placing the following at the top of app/assets/stylesheets/materialize.scss before the @import line:

$roboto-font-path: 'materialize/font/roboto/';

This should fix the problem and Rails will now serve the Roboto font directly from the vendor/bower_components/materialize/font/roboto.

So let's push it to Heroku and see what happens...*

*Alternatively, run it locally in production mode using $ bin/rails server -e production. You will need to compile the assets first using $ bin/rake assets:precompile

Step 3. Fixing Production

Once again, we are getting an error on the fonts. But why? This is where it starts to get a little thorny.

When deploying to production, your assets will be precompiled. The asset pipeline will only compile assets found through the Sprockets directives in app/assets/stylesheets/application.css or imported using Sass. It will also compile fonts in app/assets/fonts. But our fonts are not in app/assets/fonts. We could copy them across, but I would rather serve them from our vendor/bower_components/materialize/font folder rather than having two versions of each file in my project.

To do this, we need to add the following to config/initializers/assets.rb*:

Rails.application.config.assets.precompile << /materialize\/font\/.+\.(?:svg|eot|woff|woff2|ttf)\z/  

*Rails 4.2+. For earlier versions, put these in config/application.rb or config/environments/production.rb

This will tell Rails to precompile any files matching the given pattern, which will include our Materialize fonts.

You can test this by running:

$ bin/rake assets:precompile

And then checking the contents of public/assets/materialize/font. Now run $ bin/rake assets:clobber to remove the locally-created assets before pushing to Heroku.

Try deploying to Heroku again. Has this fixed the problem?

We are now faced with a different problem. Materialize is trying to load /assets/materialize/font/roboto/Roboto-Regular.woff say, but we are still getting a 404 error.

If you check in public/assets/materialize/font/roboto you will see that there is no file with the name Roboto-Regular.woff. All of the files have been fingerprinted by the asset compilation and so have hashes appended to their filenames.

We can overcome this is in our own Sass files by using the special helper method font-url e.g. src: font-url("#{$roboto-font-path}Roboto-Regular.woff") which will render the correctly fingerprinted filename. However, the Roboto font is defined by Materialize in _roboto.scss and we have no way to directly influence that.

There are two ways we can approach this problem:

Option 1. Redefine the Materialize font paths

Redefine all of the fonts in our app/assets/stylesheets/materialize.scss file after the @import line like this:

@font-face {
    font-family: "Roboto";
    src: font-url("#{$roboto-font-path}Roboto-Thin.woff2") format("woff2"),
        font-url("#{$roboto-font-path}Roboto-Thin.woff") format("woff"),
        font-url("#{$roboto-font-path}Roboto-Thin.ttf") format("truetype");
    font-weight: 200;
}
@font-face {
    font-family: "Roboto";
    src: font-url("#{$roboto-font-path}Roboto-Light.woff2") format("woff2"),
        font-url("#{$roboto-font-path}Roboto-Light.woff") format("woff"),
        font-url("#{$roboto-font-path}Roboto-Light.ttf") format("truetype");
    font-weight: 300;
}

@font-face {
    font-family: "Roboto";
    src: font-url("#{$roboto-font-path}Roboto-Regular.woff2") format("woff2"),
        font-url("#{$roboto-font-path}Roboto-Regular.woff") format("woff"),
        font-url("#{$roboto-font-path}Roboto-Regular.ttf") format("truetype");
    font-weight: 400;
}

@font-face {
    font-family: "Roboto";
    src: font-url("#{$roboto-font-path}Roboto-Medium.woff2") format("woff2"),
        font-url("#{$roboto-font-path}Roboto-Medium.woff") format("woff"),
        font-url("#{$roboto-font-path}Roboto-Medium.ttf") format("truetype");
    font-weight: 500;
}

@font-face {
    font-family: "Roboto";
    src: url("#{$roboto-font-path}Roboto-Bold.woff2") format("woff2"),
        url("#{$roboto-font-path}Roboto-Bold.woff") format("woff"),
        url("#{$roboto-font-path}Roboto-Bold.ttf") format("truetype");
    font-weight: 700;
}

But that feels somewhat brittle and, furthermore, is bespoke to Materialize.

Option 2. Enable the current Materialize font paths

Credit to Bibliographic Wilderness for inspiring this approach!

What we will do is hook into the assets compilation and, for selected assets, create a 'non-digested' version of the file.

Firstly, we will create a new assets config called non_digested_assets and add the materialize font path pattern to it. In config/initializers/assets.rb add the following:

Rails.application.config.assets.non_digested_assets ||= []  
Rails.application.config.assets.non_digested_assets << 'materialize/font/**/*'  

Next, we need to create a custom rake task to hook into assets:precompile and create our non-digested assets. Create the following file lib/tasks/create_non_digested_assets.rake:

# Every time assets:precompile is called, trigger assets:create_non_digested_assets.
Rake::Task["assets:precompile"].enhance do  
  Rake::Task["assets:create_non_digested_assets"].invoke
end

namespace :assets do

  logger = Logger.new($stderr)

  task :create_non_digested_assets => :"assets:environment"  do
    manifest_path = Dir.glob(File.join(Rails.root, 'public/assets/manifest-*.json')).first
    manifest_data = JSON.load(File.new(manifest_path))

    manifest_data["assets"].each do |logical_path, digested_path|
      logical_pathname = Pathname.new logical_path

      if Rails.application.config.assets.non_digested_assets.any? {|pattern| logical_pathname.fnmatch?(pattern, File::FNM_PATHNAME) }
        full_digested_path    = File.join(Rails.root, 'public/assets', digested_path)
        full_nondigested_path = File.join(Rails.root, 'public/assets', logical_path)

        logger.info "Copying to #{full_nondigested_path}"

        # Use FileUtils.copy_file with true third argument to copy
        # file attributes (eg mtime) too, as opposed to FileUtils.cp
        FileUtils.copy_file full_digested_path, full_nondigested_path, true
      end
    end
  end
end  

Now try running $ bin/rake assets:precompile. You should see the non-digested font assets in public/assets/materialize/font/roboto.

Run $ bin/rake assets:clobber again to clear out the locally created assets before deploying to Heroku.

Now, finally, this should work.

The benefit of this option is it provides a consistent approach to handling assets for any framework; should the need arise to reference non-digested asset filenames.