[249] Webpack, Phoenix, and Elm

A walkthrough of starting a new webpack-based project in Phoenix, getting most of the existing out-of-the-box Brunch feature support, and introducing Elm compilation and scss as well.

Subscribe now

Webpack, Phoenix, and Elm [06.11.2016]

In Episode 240, we covered using Phoenix and Elm together, by way of Brunch. There are various reasons someone might prefer Webpack, and I'm falling into that camp with Elm and Phoenix, so let's see how to get a great setup together for using Webpack to build your Elm assets in your Phoenix application.

Project

First off, let me give a huge thanks to Nathan Leniz for writing the post linked in the Resources section. It's a great resource for this exact thing, and I've referenced it more than a few times.

Having gotten that out of the way, on to a project.

We'll start a new phoenix project:

mix phoenix.new project_hydra
cd project_hydra
mix ecto.create

Replacing brunch with webpack

Initial setup

So that's a basic phoenix app in place. Let's rip out brunch and replace it with webpack. First, we can remove the brunch config:

rm brunch-config.js

And open package.json to remove the brunch packages:

vim package.json
{
  "repository": {},
  "license": "MIT",
  "scripts": {
  },
  "dependencies": {
    "phoenix": "file:deps/phoenix",
    "phoenix_html": "file:deps/phoenix_html"
  },
  "devDependencies": {
  }
}

We'll install webpack with npm:

npm install --save-dev webpack
# Then we configure it
vim webpack.config.js

Adding JavaScript support

module.exports = {
  entry: "./web/static/js/app.js",
  output: {
    path: "./priv/static/js",
    filename: "app.js"
  }
}

Next we'll add an npm scripts entry to run webpack with some decent command line options. Open up package.json

{
  "repository": {},
  "license": "MIT",
  "scripts": {
    "start": "webpack --watch-stdin --progress --color"
  },
  "dependencies": {
    "phoenix": "file:deps/phoenix",
    "phoenix_html": "file:deps/phoenix_html"
  },
  "devDependencies": {
    "webpack": "^1.13.1"
  }
}

So now we can run webpack with npm start:

npm start

Here we can see that it emitted app.js for us.

We don't want to have to run it on our own though - we want phoenix to manage this for us. We'll open up the dev configuration and make that happen:

vim config/dev.exs
config :project_hydra, ProjectHydra.Endpoint,
  # ...
  # The cd option here says where to start the command from
  watchers: [npm: ["start", cd: Path.expand("../", __DIR__)]]

We'll run our phoenix app...

mix phoenix.server

Let's open up the app in a browser at http://localhost:4000.

We'll leave that running and open a new terminal tab.

Open up the app.js file:

vim web/static/js/app.js

Replace everything with a simple alert for now:

alert("hello from webpack");

Next, we want support for ES2015 syntax. To do this, we'll add some babel packages.

npm install --save-dev babel-core babel-loader babel-preset-es2015

Now we'll configure the babel loader for webpack:

vim webpack.config.js
module.exports = {
  entry: "./web/static/js/app.js",
  output: {
    path: "./priv/static/js",
    filename: "app.js"
  },
  module: {
    loaders: [{
      test: /\.js$/,
      exclude: /node_modules/,
      loader: "babel",
      query: {
        presets: ["es2015"]
      }
    }]
  }
}

Here we've configured webpack to run every file ending in .js through babel, and we've configured babel to use the es2015 preset.

webpack will expect explicit paths to all files by default. We want to relax this a bit and give it some default paths to look in: node_modules for packages we install, and ./web/static/js for our application JavaScript files.

module.exports = {
  // --- },
  resolve: {
    modulesDirectories: [
      "node_modules",
      __dirname + "/web/static/js"
    ]
  }
}

Now we can go into the app.js and bring phoenix_html back in phoenix_html as well as import Socket just in case we want it later:

import "phoenix_html"
import { Socket } from "phoenix"
alert("hello from webpack")

If we refresh the browser, we can see that the alert worked which means that webpack was able to import phoenix_html and the Socket module. This is pretty great. We're basically set up for JavaScript now.

Adding CSS support

By default, webpack will inject css via the compiled JavaScript. We'd like to keep our css files building like they do in brunch though.

We can add 2 webpack loaders and a plugin to handle this:

npm install --save-dev css-loader style-loader extract-text-webpack-plugin

We'll update webpack.config.js to use these:

// We need to bring in the ExtractTextPlugin.  This will allow us to pull the
// CSS out of our bundle and output it to its own file.
var ExtractTextPlugin = require("extract-text-webpack-plugin");
module.exports = {
  // We'll add an entry point for CSS, which means our `entry` field must now
  // be an array
  entry: ["./web/static/css/app.css", "./web/static/js/app.js"],
  // since we're no longer just outputting js, we'll tweak our output path and
  // output filename for js.
  output: {
    path: "./priv/static",
    filename: "js/app.js"
  },

  module: {
    loaders: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: "babel",
        query: {
          presets: ["es2015"]
        }
      },
      // We'll add a css loader
      {
        test: /\.css$/,
        loader: ExtractTextPlugin.extract("style", "css")
      }
    ]
  },

  // And we'll add the plugin configuration
  plugins: [
    new ExtractTextPlugin("css/app.css")
  ],

  resolve: {
    modulesDirectories: [
      "node_modules",
      __dirname + "/web/static/js"
    ]
  }
};

Now, webpack will compile web/static/css/app.css and place it in priv/static/css/app.css. Our CSS no longer has the bootstrap bits in place though as a consequence. Before we get to that, let's switch to scss because that's what I loves.

Adding Sass support

First, we'll install node-sass:

npm install --save-dev node-sass sass-loader

Then we'll update the webpack.config.js:

var ExtractTextPlugin = require("extract-text-webpack-plugin");
module.exports = {
  // We'll change our entry point to look for an scss file instead
  entry: ["./web/static/css/app.scss", "./web/static/js/app.js"],
  output: {
    path: "./priv/static",
    filename: "js/app.js"
  },

  module: {
    loaders: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: "babel",
        query: {
          presets: ["es2015"]
        }
      },
      {
        test: /\.css$/,
        loader: ExtractTextPlugin.extract("style", "css")
      },
      // We'll configure an scss loader
      {
        test: /\.scss$/,
        loader: ExtractTextPlugin.extract(
          "style",
          "css!sass?includePaths[]=" + __dirname + "/node_modules"
        )
      }
    ]
  },

  plugins: [
    new ExtractTextPlugin("css/app.css")
  ],

  resolve: {
    modulesDirectories: [
      "node_modules",
      __dirname + "/web/static/js"
    ]
  }
};

Let's move app.css to app.scss and add some scss to test it out:

mv web/static/css/app.{,s}css
vim web/static/css/app.scss
body {
  p {
    color: red;
  }
}

If we restart phoenix and visit it now, our css is showing up. Let's pull in bootstrap again, even though I don't love it - for potentially specious reasons - as I know that people will want to know how to do this.

Adding bootstrap support

npm install --save-dev bootstrap-sass
vim web/static/css/app.scss
/* This file is for your main application css. */
$icon-font-path: "~bootstrap-sass/assets/fonts/bootstrap/";
@import "bootstrap-sass/assets/stylesheets/_bootstrap.scss";

body {
  p {
    color: red;
  }
}

If you try to run the server now...you'll get a host of issues, because we don't have loaders for svg, or ttf, or woff, or woff2, or eot. We'll add those, but first we need to pull in the file loader and the url loader:

npm install --save-dev file-loader url-loader
var ExtractTextPlugin = require("extract-text-webpack-plugin");
module.exports = {
  entry: ["./web/static/css/app.scss", "./web/static/js/app.js"],
  output: {
    path: "./priv/static",
    filename: "js/app.js"
  },

  module: {
    loaders: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: "babel",
        query: {
          presets: ["es2015"]
        }
      },
      {
        test: /\.css$/,
        loader: ExtractTextPlugin.extract("style", "css")
      },
      {
        test: /\.scss$/,
        loader: ExtractTextPlugin.extract(
          "style",
          "css!sass?includePaths[]=" + __dirname + "/node_modules"
        )
      },
      // We'll add the font and SVG loaders that we need
      {
        test: /\.(woff|woff2)(\?v=\d+\.\d+\.\d+)?$/, loader: 'url?limit=10000&mimetype=application/font-woff'
      },
      {
        test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: 'url?limit=10000&mimetype=application/octet-stream'
      },
      {
        test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: 'file'
      },
      {
        test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: 'url?limit=10000&mimetype=image/svg+xml'
      }
    ]
  },

  plugins: [
    new ExtractTextPlugin("css/app.css")
  ],

  resolve: {
    modulesDirectories: [
      "node_modules",
      __dirname + "/web/static/js"
    ]
  }
};

OK, so if we run the server and refresh, it pulls in bootstrap, but at least for my initial test run I lost the main application css that was shipped. I'm not sure where that happened, or if it will happen for you. At any rate, you can see that the CSS is being loaded for bootstrap.

Configuring webpack for Elm files

Now we've got a good base webpack configuration, and we can move on to supporting Elm. First things first, we'll get an Elm application in place.

mkdir web/elm
cd web/elm
elm package install -y elm-lang/html
vim Main.elm
module Main exposing (..)

import Html exposing (text)


main =
    text "hello, phoenix"

Now we'll update the app.js file to make it mount our elm application into a div:

import "phoenix_html"
import { Socket } from "phoenix"

const Elm = require('../../elm/Main')
Elm.Main.embed(document.getElementById('elm-main'));

And we'll add a div with that id to our main page template:

vim web/templates/page/index.html.eex
<div id="elm-main"></div>

All that's left is to pull in an elm webpack loader:

npm install --save-dev elm-webpack-loader
vim webpack.config.js
const ExtractTextPlugin = require("extract-text-webpack-plugin")
// We'll add a const for where our elm source files live
const elmSource = __dirname + '/web/elm'

module.exports = {
  entry: [
    "./web/static/css/app.scss",
    "./web/static/js/app.js",
    // We need to add our elm app as an entry point
    "./web/elm/Main.elm"
  ],
  output: {
    path: "./priv/static",
    filename: "js/app.js"
  },

  module: {
    loaders: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: "babel",
        query: {
          presets: ["es2015"]
        }
      },
      {
        test: /\.css$/,
        loader: ExtractTextPlugin.extract("style", "css")
      },
      {
        test: /\.scss$/,
        loader: ExtractTextPlugin.extract(
          "style",
          "css!sass?includePaths[]=" + __dirname + "/node_modules"
        )
      },
      {
        test: /\.(woff|woff2)(\?v=\d+\.\d+\.\d+)?$/, loader: 'url?limit=10000&mimetype=application/font-woff'
      },
      {
        test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: 'url?limit=10000&mimetype=application/octet-stream'
      },
      {
        test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: 'file'
      },
      {
        test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: 'url?limit=10000&mimetype=image/svg+xml'
      },
      // We'll add our elm loader
      {
        test: /\.elm$/,
        exclude: [/elm-stuff/, /node_modules/],
        loader: 'elm-webpack?cwd=' + elmSource
      }
    ],
    // And we don't want to parse Elm files since they won't be using require or define calls
    noParse: [/\.elm$/]
  },

  plugins: [
    new ExtractTextPlugin("css/app.css")
  ],

  resolve: {
    modulesDirectories: [
      "node_modules",
      __dirname + "/web/static/js"
    ],
    // We need webpack to know it can resolve elm files
    extensions: ['', '.scss', '.css', '.js', '.elm']
  }
}

With that, if we restart the server we can refresh the page and see our hello, phoenix text being output on the page.

Summary

In today's episode, we started a new phoenix application and replaced brunch with webpack. This required us to get a good webpack configuration set up from scratch, which I outlined step-by-step so it's easy to understand what happened. Finally, we built a tiny Elm application and taught webpack to compile it as well. This should provide a good starting point for people interested in bringing Elm into their applications but unsatisfied with the workflow under Brunch. I hope you enjoyed it. See you soon!

Resources