tl;dr
MBU is a collection of functions and a few macros that I wrote to help in using Mix as a build tool for e.g. frontend building. It supports task dependencies, and watching paths (either with watchers built in to different tools or custom watchers using file_system
). You can find it on Hex, documentation on Hexdocs, and code on GitLab.
Motivation
This blog post explains my motivations best (has old code samples but the motivations are the same). The short version is that I was tired of learning different frontend build systems that all seemed to be magical, fragile, or difficult to maintain. I thought about Make and NPM scripts but ended up with my own solution that would serve my own needs: use Mix as a build tool to call the usual Node tools, so that I can write my build scripts in Elixir and know how they work.
If I succeeded in creating anything useful is left up to the reader.
How it works
Let’s suppose the following config:
config :mbu,
auto_paths: true, # Autogenerate output path for tasks
create_out_paths: true, # Create output path when task runs
tmp_path: Path.expand(".tmp") # Path under which autogenerated output paths are, i.e. where task output is stored
Then we could have the following build task:
defmodule Mix.Task.Css.Compile do
use MBU.BuildTask
@deps ["css.autoprefix", "assets.copy"]
def in_path(), do: Path.join("assets", "css") |> Path.expand()
def in_file(), do: Path.join(in_path(), "frontend.scss")
def args() do
[
"--source-map-embed",
"--output",
out_path(),
in_file()
]
end
task _ do
exec("node_modules/.bin/node-sass", args()) |> listen()
out_file = Path.basename(in_file(), "scss") <> "css"
print_size(Path.join(out_path(), out_file))
end
end
What this task does is:
- Runs the dependencies listed in
@deps
in parallel before the task is run. - Creates an output directory for the build artifacts based on the task name and
:tmp_path
, in this case it would be something like/path/to/proj/.tmp/css.compile
. This path is returned byout_path/0
. - Executes the
node-sass
command with the given arguments, waits for it to complete and prints the output. - Prints the size of the compiled file using
print_size/1
.
Now if we wanted to watch the assets/css
directory for changes, we could create another task and in that task’s task
block, we can do:
watch("FrontendCompileCSS", Mix.Task.Css.Compile.in_path(), Mix.Task.Css.Compile)
|> listen(watch: true)
What this does is it listens to the path given in the second argument for changes, and then runs the task given in the third argument (the first argument is for logging purposes). The last argument could also be an anonymous function to call. The watch: true
option makes the listen/2
function also listen for an enter key from the user, which will stop the watching.
This was just a small example, for more realistic usage, see my website’s build tasks at https://gitlab.com/code-stats/code-stats/tree/master/lib/mix/tasks (probably a bit overengineered at the moment as it will have more complex targets in the future).
Pros & cons
I am obviously biased and blind to my own creation but here’s what I think of it:
- Pros:
- Minimal features & magic
- Get to write Elixir, no external deps
- Easier to fix/debug when build breaks because it’s just small Elixir tasks
- Cons:
- Minimal features & magic
- Verbose especially for small tasks
- Some tools don’t have command line interfaces to call
- Needs more testing on Windows
I hope it can be useful to someone or at least can inspire for better Mix helpers in the future (since the code is really not that nice). I use it in my own projects and it seems to work well for me, and now at least I can only blame myself if something goes wrong in the build. If you use it for something, please let me know any feedback you have in this thread. I will also use this thread to notify if I update MBU with new stuff.