Use totally new build system written in Elixir

This allows for fewer npm dependencies and fewer packages to handle (some
of the currently installed babel packages are for possible future use).
Hopefully the new build style is more explicit even though it is more
verbose. Both building in dev and prod mode and watching is supported.
This commit is contained in:
Mikko Ahlroth 2017-01-21 23:01:39 +02:00
parent b80cb5433f
commit c733b37389
24 changed files with 727 additions and 105 deletions

7
.babelrc Normal file
View file

@ -0,0 +1,7 @@
{
"presets": [
["es2015", {"modules": false}],
"es2016",
"es2017"
]
}

2
.gitignore vendored
View file

@ -20,7 +20,7 @@ config/*.exs
# we ignore priv/static/{css,js}. You may want to
# comment this depending on your deployment strategy.
/priv/static/
/.tmp/
# Custom templates should be ignored
/web/templates/custom/

View file

@ -13,7 +13,7 @@ The engine consists of two parts:
* `git clone`
* Copy `config/*.exs.dist`, removing the `.dist` ending and go through the configs.
* `npm install && gulp` to build the frontend.
* `npm install && mix frontend.build` to build the frontend.
* Put some content into the data path you specified, at least a `menu` file.
* `mix phoenix.server` to run the development server.

View file

@ -1,76 +0,0 @@
// Node modules
var fs = require('fs'), vm = require('vm'), chalk = require('chalk');
// Gulp and plugins
var gulp = require('gulp'), concat = require('gulp-concat'),
replace = require('gulp-replace'), uglify = require('gulp-uglify');
// Gulp minify for smallinizing our CSS
var minify = require('gulp-minify-css');
// Gulp filesize for printing sizes before and after minification
var size = require('gulp-size');
// SASS compiler
var sass = require('gulp-sass');
// Deleting files
var vinylPaths = require('vinyl-paths');
var del = require('del');
// Source maps
var sourcemaps = require('gulp-sourcemaps');
var node_path = 'node_modules/';
var dest_path = 'priv/static/';
// Minifies all JS files and copies to target path. Files are not concatenated
gulp.task('js', function() {
return gulp.src([
// Uncomment these two lines if you need bootstrap or jQuery
//node_path + 'jquery/dist/jquery.js',
//node_path + 'bootstrap-sass/assets/javascripts/bootstrap.js',
'web/static/js/*.js'
])
//.pipe(concat('app.js'))
.pipe(sourcemaps.init())
.pipe(size({title: 'Original JS'}))
.pipe(uglify({ preserveComments: false }).on('error', console.error.bind(console)))
.pipe(size({title: 'Minified JS'}))
.pipe(sourcemaps.write('./'))
.pipe(gulp.dest(dest_path + 'js/'));
});
// Compiles SASS files. Will create one output file for each input file, but usually you
// only have app.scss which will be output as app.css
gulp.task('css', function() {
return gulp.src([
'web/static/css/*.scss'
])
.pipe(sass().on('error', sass.logError))
.pipe(sourcemaps.init())
.pipe(size({title: 'Compiled CSS'}))
.pipe(minify())
.pipe(size({title: 'Minified CSS'}))
.pipe(sourcemaps.write('./'))
.pipe(gulp.dest(dest_path + 'css/'));
});
// Copies fonts
gulp.task('fonts', function() {
return gulp.src(node_path + 'bootstrap-sass/assets/fonts/bootstrap/*')
.pipe(gulp.dest(dest_path + 'fonts/'));
});
// Removes all files from dest_path
gulp.task('clean', function() {
return gulp.src(dest_path + '**/*', { read: false })
.pipe(vinylPaths(del));
});
gulp.task('default', ['js', 'css', 'fonts'], function(callback) {
callback();
console.log('\nPlaced optimized files in ' + chalk.magenta(dest_path));
});

View file

@ -9,6 +9,7 @@ defmodule MebeWeb.Endpoint do
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
plug Phoenix.LiveReloader
plug Phoenix.CodeReloader
end

View file

@ -0,0 +1,72 @@
defmodule AppRunner do
def wait_for_eof() do
case IO.getn("", 1024) do
:eof -> nil
_ -> wait_for_eof()
end
end
def exec(program, args) do
Port.open(
{:spawn_executable, program},
[
:exit_status,
:stderr_to_stdout, # Redirect stderr to stdout to log properly
args: args,
line: 1024
]
)
end
def get_pid(port) do
case Port.info(port) do
nil ->
nil
info when is_list(info) ->
case Keyword.get(info, :os_pid) do
nil -> nil
pid -> Integer.to_string(pid)
end
end
end
def kill(pid) do
System.find_executable("kill") |> System.cmd([pid])
end
def wait_loop(port) do
receive do
{_, {:data, {:eol, msg}}} ->
msg |> :unicode.characters_to_binary(:unicode) |> IO.puts()
{_, {:data, {:noeol, msg}}} ->
msg |> :unicode.characters_to_binary(:unicode) |> IO.write()
{_, :eof_received} ->
get_pid(port) |> kill()
:erlang.halt(0)
{_, :closed} ->
:erlang.halt(0)
{_, {:exit_status, status}} ->
:erlang.halt(status)
{:EXIT, _, _} ->
:erlang.halt(1)
end
wait_loop(port)
end
end
[program | args] = System.argv()
port = AppRunner.exec(program, args)
Task.async(fn ->
AppRunner.wait_for_eof()
:eof_received
end)
AppRunner.wait_loop(port)

View file

@ -0,0 +1,14 @@
defmodule Mix.Tasks.Frontend.Build.Assets do
use Mix.Task
import MebeWeb.{FrontendConfs}
@shortdoc "Copy other assets to target dir"
def run(_) do
# Ensure target path exists
out_path = "#{dist_path()}/fonts"
File.mkdir_p!(out_path)
File.cp_r!("#{node_path()}/bootstrap-sass/assets/fonts/bootstrap", out_path)
end
end

View file

@ -0,0 +1,31 @@
defmodule Mix.Tasks.Frontend.Build.Css.Compile do
use Mix.Task
import MebeWeb.{TaskUtils, FrontendConfs}
@shortdoc "Build the SCSS sources"
def bin(), do: node_bin("node-sass")
def out_path(:prod), do: "#{tmp_path()}/compiled/css"
def out_path(_), do: "#{dist_path()}/css"
def args(), do: [
"-o",
out_path(Mix.env()),
"--source-map",
"true",
"--include-path",
"#{node_path()}/bootstrap-sass/assets/stylesheets",
"--precision",
"8"
]
def scss_file(), do: "#{src_path()}/css/app.scss"
def run(_) do
exec(
bin(),
args() ++ [scss_file()]
) |> listen()
end
end

View file

@ -0,0 +1,15 @@
defmodule Mix.Tasks.Frontend.Build.Css do
use Mix.Task
import MebeWeb.TaskUtils
@shortdoc "Build the frontend CSS"
def run(_) do
js_task = case Mix.env() do
:prod -> "frontend.build.css.minify"
_ -> "frontend.build.css.compile"
end
run_task(js_task)
end
end

View file

@ -0,0 +1,30 @@
defmodule Mix.Tasks.Frontend.Build.Css.Minify do
use Mix.Task
import MebeWeb.{TaskUtils, FrontendConfs}
@shortdoc "Minify built CSS files"
@preferred_cli_env :prod
def run(_) do
run_task("frontend.build.css.compile")
in_path = "#{tmp_path()}/compiled/css"
in_file = "#{in_path}/app.css"
out_path = "#{dist_path()}/css"
out_file = "#{out_path}/app.css"
File.mkdir_p!(out_path)
exec(
node_bin("cssnano"),
[
in_file,
out_file,
"--sourcemap",
"#{out_path}/app.css.map"
]
) |> listen()
print_size(out_file, in_file)
end
end

View file

@ -0,0 +1,16 @@
defmodule Mix.Tasks.Frontend.Build do
use Mix.Task
import MebeWeb.TaskUtils
@shortdoc "Build the frontend"
def run(_) do
run_task("frontend.clean")
run_tasks([
"frontend.build.js",
"frontend.build.css",
"frontend.build.assets"
])
end
end

View file

@ -0,0 +1,37 @@
defmodule Mix.Tasks.Frontend.Build.Js.Bundle do
use Mix.Task
import MebeWeb.{TaskUtils, FrontendConfs}
@shortdoc "Bundle the JavaScript sources into app.js"
def bin(), do: node_bin("rollup")
def out_path(:prod), do: "#{tmp_path()}/bundled/js"
def out_path(_), do: "#{dist_path()}/js"
def args() do
op = out_path(Mix.env())
[
"--config",
"rollup.config.js",
"--input",
"#{tmp_path()}/transpiled/js/app.js",
"--output",
"#{op}/app.js",
"--format",
"cjs",
"--sourcemap",
"#{op}/app.js.map"
]
end
def run(_) do
run_task("frontend.build.js.transpile")
exec(
bin(),
args()
) |> listen()
end
end

View file

@ -0,0 +1,15 @@
defmodule Mix.Tasks.Frontend.Build.Js do
use Mix.Task
import MebeWeb.TaskUtils
@shortdoc "Build the frontend JavaScript"
def run(_) do
js_task = case Mix.env() do
:prod -> "frontend.build.js.minify"
_ -> "frontend.build.js.bundle"
end
run_task(js_task)
end
end

View file

@ -0,0 +1,38 @@
defmodule Mix.Tasks.Frontend.Build.Js.Minify do
use Mix.Task
import MebeWeb.{TaskUtils, FrontendConfs}
@shortdoc "Minify built JS files"
@preferred_cli_env :prod
def run(_) do
run_task("frontend.build.js.bundle")
in_path = "#{tmp_path()}/bundled/js"
in_file = "#{in_path}/app.js"
out_path = "#{dist_path()}/js"
out_file = "#{out_path}/app.js"
File.mkdir_p!(out_path)
exec(
node_bin("uglifyjs"),
[
"--in-source-map",
"#{in_path}/app.js.map",
"--source-map",
"#{out_path}/app.js.map",
"--source-map-url",
"app.js.map",
"--screw-ie8",
"-m",
"-o",
out_file,
"--",
in_file
]
) |> listen()
print_size(out_file, in_file)
end
end

View file

@ -0,0 +1,23 @@
defmodule Mix.Tasks.Frontend.Build.Js.Transpile do
use Mix.Task
import MebeWeb.{TaskUtils, FrontendConfs}
@shortdoc "Transpile JS sources to ES5"
def bin(), do: node_bin("babel")
def args(), do: [
"#{src_path()}/js",
"--out-dir",
"#{tmp_path()}/transpiled/js",
"--source-maps",
"inline"
]
def run(_) do
exec(
bin(),
args()
) |> listen()
end
end

View file

@ -0,0 +1,11 @@
defmodule Mix.Tasks.Frontend.Clean do
use Mix.Task
import MebeWeb.{FrontendConfs}
@shortdoc "Clean build artifacts"
def run(_) do
File.rm_rf!(tmp_path())
File.rm_rf!(dist_path())
end
end

View file

@ -0,0 +1,33 @@
defmodule Mix.Tasks.Frontend.Watch do
use Mix.Task
import MebeWeb.{TaskUtils, FrontendConfs}
alias Mix.Tasks.Frontend.Build.Js.Transpile, as: TranspileJS
alias Mix.Tasks.Frontend.Build.Js.Bundle, as: BundleJS
alias Mix.Tasks.Frontend.Build.Css.Compile, as: CompileCSS
@shortdoc "Watch frontend and rebuild when necessary"
def run(_) do
run_task("frontend.build")
[
exec(
TranspileJS.bin(),
TranspileJS.args() ++ ["-w"]
),
exec(
BundleJS.bin(),
BundleJS.args() ++ ["-w"]
),
exec(
CompileCSS.bin(),
CompileCSS.args() ++ [
"-w",
CompileCSS.scss_file()
]
)
] |> watch()
end
end

View file

@ -0,0 +1,34 @@
defmodule MebeWeb.FrontendConfs do
import MebeWeb.TaskUtils
@moduledoc """
Project specific paths and other stuff for Mebe frontend.
"""
@doc """
Get absolute path to node_modules.
"""
def node_path() do
"#{proj_path()}/node_modules"
end
@doc """
Get absolute path to binary installed with npm.
"""
def node_bin(executable), do: "#{node_path()}/.bin/#{executable}"
@doc """
Get absolute path to source directory for frontend build.
"""
def src_path(), do: "#{proj_path()}/web/static"
@doc """
Get absolute path to temp directory for build artifacts.
"""
def tmp_path(), do: "#{proj_path()}/.tmp"
@doc """
Get absolute path to target directory for frontend build.
"""
def dist_path(), do: "#{proj_path()}/priv/static"
end

311
lib/mix/tasks/utils.ex Normal file
View file

@ -0,0 +1,311 @@
defmodule MebeWeb.TaskUtils do
@moduledoc """
Utilities for project build tasks.
"""
require Logger
@elixir System.find_executable("elixir")
@default_task_timeout 60000
defmodule Program do
@moduledoc """
Program to execute with arguments. Name is used for prefixing logs.
"""
defstruct [
name: "",
port: nil,
pending_output: ""
]
end
@doc """
Get configuration value.
"""
def conf(key) when is_atom(key) do
Application.get_env(:code_stats, key)
end
@doc """
Get path to project root directory.
"""
def proj_path() do
Path.expand("../../..", __DIR__)
end
@doc """
Get absolute path to a program in $PATH.
"""
def exec_path(program) do
System.find_executable(program)
end
@doc """
Run the given Mix task and wait for it to stop before returning.
See run_tasks/2 for the argument description.
"""
def run_task(task, timeout \\ @default_task_timeout) do
run_tasks([task], timeout)
end
@doc """
Run the given Mix tasks in parallel and wait for them all to stop
before returning.
Tasks should be tuples {task_name, args} or binaries (no args).
"""
def run_tasks(tasks, timeout \\ 60000) do
run_and_log = fn task, args ->
Logger.info("[Started] #{task}")
Mix.Task.run(task, args)
Logger.info("[Finished] #{task}")
end
tasks
|> Enum.map(fn
task when is_binary(task) ->
fn -> run_and_log.(task, []) end
{task, args} ->
fn -> run_and_log.(task, args) end
end)
|> run_funs(timeout)
end
@doc """
Run the given functions in parallel and wait for them all to stop
before returning.
Functions can either be anonymous functions or tuples of
{module, fun, args}.
"""
def run_funs(funs, timeout \\ 60000) when is_list(funs) do
funs
|> Enum.map(fn
fun when is_function(fun) ->
Task.async(fun)
{module, fun, args} ->
Task.async(module, fun, args)
end)
|> Enum.map(fn task ->
Task.await(task, timeout)
end)
end
@doc """
Start an external program with apprunner.
Apprunner handles killing the program if BEAM is abruptly shut down.
Name is used as a prefix for logging output.
Options that can be given:
- name: Use as name for logging, otherwise name of binary is used.
- cd: Directory to change to before executing.
"""
def exec(executable, args, opts \\ []) do
name = Keyword.get(
opts,
:name,
(executable |> Path.rootname() |> Path.basename())
)
options = [
:exit_status, # Send msg with status when command stops
args: ["#{proj_path()}/lib/mix/tasks/apprunner.exs" | [executable | args]],
line: 1024 # Send command output as lines of 1k length
]
options = case Keyword.get(opts, :cd) do
nil -> options
cd -> Keyword.put(options, :cd, cd)
end
Logger.debug("[Spawned] #{name}")
%Program{
name: name,
pending_output: "",
port: Port.open(
{:spawn_executable, @elixir},
options
)
}
end
@doc """
Listen to messages from programs and print them to the screen.
Also listens to input from user and returns if input is given.
"""
def listen(programs, task \\ nil, opts \\ [])
def listen([], _, _), do: nil
def listen(%Program{} = program, task, opts) do
listen([program], task, opts)
end
def listen(programs, task, opts) do
# Start another task to ask for user input if we are in watch mode
task = with \
true <- Keyword.get(opts, :watch, false),
nil <- task,
{:ok, task} <- Task.start_link(__MODULE__, :wait_for_input, [self()])
do
task
end
programs = receive do
:user_input_received ->
[]
{port, {:data, {:eol, msg}}} ->
program = Enum.find(programs, get_port_checker(port))
msg = :unicode.characters_to_binary(msg, :unicode)
prefix = "[#{program.name}] #{program.pending_output}"
Logger.debug(prefix <> msg)
programs
|> Enum.reject(get_port_checker(port))
|> Enum.concat([
%{program | pending_output: ""}
])
{port, {:data, {:noeol, msg}}} ->
program = Enum.find(programs, get_port_checker(port))
msg = :unicode.characters_to_binary(msg, :unicode)
programs
|> Enum.reject(get_port_checker(port))
|> Enum.concat([
%{program | pending_output: "#{program.pending_output}#{msg}"}
])
# Port was closed normally after being told to close
{port, :closed} ->
handle_closed(programs, port)
# Port closed because the program closed by itself
{port, {:exit_status, 0}} ->
handle_closed(programs, port)
# Program closed with error status
{port, {:exit_status, status}} ->
program = Enum.find(programs, get_port_checker(port))
Logger.error("Program #{program.name} returned status #{status}.")
raise "Failed status #{status} from #{program.name}!"
# Port crashed
{:EXIT, port, _} ->
handle_closed(programs, port)
end
if not Enum.empty?(programs) do
listen(programs, task, opts)
end
end
@doc """
Kill a running program returned by exec().
"""
def kill(%Program{name: name, port: port}) do
if name != nil do
Logger.debug("[Killing] #{name}")
end
send(port, {self(), :close})
end
@doc """
Print output from given programs to screen until user input is given.
When user input is given, kill programs and return.
"""
def watch(%Program{} = program), do: watch([program])
def watch(programs) when is_list(programs) do
Logger.info("Programs started, press ENTER to exit.")
listen(programs, nil, watch: true)
Logger.info("ENTER received, killing tasks.")
Enum.each(programs, &kill/1)
:ok
end
@doc """
Print file size of given file in human readable form.
If old file is given as second argument, print the old file's size
and the diff also.
"""
def print_size(new_file, old_file \\ nil) do
new_size = get_size(new_file)
{prefix, postfix} = if old_file != nil do
old_size = get_size(old_file)
{
"#{human_size(old_size)} -> ",
" Diff: #{human_size(old_size - new_size)}"
}
else
{"", ""}
end
Logger.debug("#{Path.basename(new_file)}: #{prefix}#{human_size(new_size)}.#{postfix}")
end
def wait_for_input(target) do
IO.gets("")
send(target, :user_input_received)
end
defp get_size(file) do
%File.Stat{size: size} = File.stat!(file)
size
end
defp human_size(size) do
size_units = ["B", "kiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"] # You never know
human_size(size, size_units)
end
defp human_size(size, [unit | []]), do: size_with_unit(size, unit)
defp human_size(size, [unit | rest]) do
if size > 1024 do
human_size(size / 1024, rest)
else
size_with_unit(size, unit)
end
end
defp size_with_unit(size, unit), do: "#{Float.round(size, 2)} #{unit}"
defp get_port_checker(port) do
fn %Program{port: program_port} ->
program_port == port
end
end
defp handle_closed(programs, port) do
case Enum.find(programs, get_port_checker(port)) do
%Program{} = program ->
Logger.debug("[Stopped] #{program.name}")
programs
|> Enum.reject(get_port_checker(port))
nil ->
# Program was already removed
programs
end
end
end

View file

@ -1,29 +1,24 @@
{
"name": "mebe_web",
"version": "1.0.0",
"version": "1.1.0",
"repository": {},
"license": "MIT",
"description": "Minimalistic Elixir Blog Engine",
"private": true,
"devDependencies": {
"bower": "~1.4.1",
"chalk": "^1.0.0",
"del": "^1.1.1",
"end-of-stream": "~0.1.5",
"event-stream": "~3.3.1",
"gulp": "^3.8.11",
"gulp-concat": "~2.5.2",
"gulp-html-replace": "~1.4.5",
"gulp-minify-css": "~1.1.1",
"gulp-replace": "~0.5.3",
"gulp-sass": "~2.0.0",
"gulp-size": "~1.2.1",
"gulp-sourcemaps": "^1.5.2",
"gulp-uglify": "~1.2.0",
"vinyl-paths": "^1.0.0"
},
"dependencies": {
"bootstrap-sass": "^3.3.5",
"jquery": "^2.1.4"
"babel-cli": "~6.22.2",
"babel-core": "~6.22.1",
"babel-polyfill": "~6.22.0",
"babel-preset-es2015": "~6.22.0",
"babel-preset-es2016": "~6.22.0",
"babel-preset-es2017": "~6.22.0",
"bootstrap-sass": "~3.3.7",
"cssnano": "~3.10.0",
"cssnano-cli": "~1.0.5",
"node-sass": "~4.3.0",
"rollup": "~0.41.4",
"rollup-plugin-sourcemaps": "~0.4.1",
"rollup-watch": "~3.2.2",
"uglify-js": "~2.7.5"
}
}

7
rollup.config.js Normal file
View file

@ -0,0 +1,7 @@
import sourcemaps from 'rollup-plugin-sourcemaps';
export default {
plugins: [
sourcemaps()
]
};

View file

@ -1 +1,8 @@
import checkForLaineHash from './old_hash_redirector';
// Check hash even before site is loaded since it doesn't need to
// wait for loading
checkForLaineHash();
window.onload = () => {
};

View file

@ -4,8 +4,8 @@
* This is done in JS because the hash is not sent to the server.
*/
(function () {
var RE = [
function checkForLaineHash() {
const RE = [
[
/^\#\!\/(\d{4})\/(\d\d)\/(\d\d)\/(.*)$/,
'/$1/$2/$3/$4'
@ -40,11 +40,11 @@
]
];
var currentHash = window.location.hash;
const currentHash = window.location.hash;
if (currentHash) {
for (var i = 0; i < RE.length; ++i) {
var results = RE[i][0].exec(currentHash);
for (let i = 0; i < RE.length; ++i) {
const results = RE[i][0].exec(currentHash);
if (results !== null) {
window.location.replace(currentHash.replace(RE[i][0], RE[i][1]));
@ -52,4 +52,6 @@
}
}
}
}());
}
export default checkForLaineHash;

View file

@ -76,7 +76,6 @@
</div> <!-- /container -->
<script src="<%= static_path(@conn, "/js/app.js") %>"></script>
<script src="<%= static_path(@conn, "/js/old_hash_redirector.js") %>"></script>
<%= raw extra_html() %>
</body>