defmodule Mix.Tasks.Grf.Build do
@shortdoc "Generates a static website with Griffin"
@moduledoc """
Generates a Griffin static site from existing template files
$ mix grf.build
A set of files will be written to the configured output directory,
`_site` by default
"""
use Mix.Task
@version Mix.Project.config()[:version]
@extensions [
# markdown
".md",
".markdown"
]
@all_options [
:input,
:output
]
@default_opts %{
input: "src",
output: "_site"
}
@switches [
# input directory
input: :string,
# output directory
output: :string
]
@aliases [
in: :input,
out: :output
]
@impl Mix.Task
def run(args, _test_opts \\ []) do
{opts, _parsed} = OptionParser.parse!(args, strict: @switches, aliases: @aliases)
# Configuration hierarchy:
# Environment Variables > Command Line Arguments > Application Config > Defaults
opts =
@default_opts
|> Map.merge(application_config())
|> Map.merge(Enum.into(opts, %{}))
|> Map.merge(environment_config())
input_path = opts.input
output_path = opts.output
files = get_workable_files(input_path)
# handle layouts
try do
:ets.new(:griffin_build_layouts, [:ordered_set, :public, :named_table])
rescue
ArgumentError ->
:ok
end
layout_files = get_layout_files()
num_layouts = length(layout_files)
# compile layout files
Enum.map(layout_files, &compile_layout/1)
partial_layouts = get_partial_layout_files()
num_partials = length(partial_layouts)
# compile partials
partials =
try do
Enum.reduce(partial_layouts, %{}, fn filepath, acc ->
Map.put(
acc,
String.to_atom(Path.basename(filepath, ".html.eex")),
EEx.compile_file(filepath)
)
end)
rescue
Enum.EmptyError ->
%{}
end
:ets.insert(:griffin_build_layouts, {:__partials__, partials})
print_compiled_layouts(num_layouts, num_partials)
# compile fallback layout
:ets.insert(
:griffin_build_layouts,
{"__fallback__", EEx.compile_string(fallback_html_layout())}
)
# Mix.shell().info("workable files #{get_workable_files(input_path)}")
# Mix.shell().info("workable layouts #{get_layout_files(input_path)}")
{time_in_microseconds, response} =
:timer.tc(fn ->
tasks =
for file <- files do
Task.async(fn ->
generate_file(file, output_path, Path.extname(file))
end)
end
for task <- tasks do
Task.await(task, :infinity)
end
end)
files_written = length(response)
time_elapsed = :erlang.float_to_binary(time_in_microseconds / 1_000_000, decimals: 2)
time_per_file =
if files_written > 0 do
:erlang.float_to_binary(time_in_microseconds / (1_000 * files_written), decimals: 1)
else
0
end
Mix.shell().info(
"Wrote #{files_written} files in #{time_elapsed} seconds (#{time_per_file}ms each, v#{@version})"
)
end
defp generate_file(input_path, output_path, extname) do
Mix.shell().info("reading: #{input_path}")
parse_result =
input_path
|> File.read!()
|> GriffinSSG.parse()
if {:error, :parsing_front_matter_failed} == parse_result do
Mix.raise("File parsing failed for file #{input_path}")
end
{:ok, %{front_matter: frontmatter, content: content}} =
input_path
|> File.read!()
|> GriffinSSG.parse()
# create full directory path
file_directory = "#{output_path}/#{Path.basename(input_path, extname)}"
Mix.shell().info("creating path: #{file_directory}")
file_directory
|> Path.expand()
|> File.mkdir_p()
file_path = "#{file_directory}/index.html"
Mix.shell().info("writing: #{file_path} from #{input_path} (#{extension_parser(extname)})")
frontmatter = frontmatter || %{}
layout_name = Map.get(frontmatter, :layout, "__fallback__")
layout =
:griffin_build_layouts
|> :ets.lookup(layout_name)
|> then(fn [{^layout_name, layout}] -> layout end)
partials =
:griffin_build_layouts
|> :ets.lookup(:__partials__)
|> then(fn [{:__partials__, partials}] -> partials end)
output =
GriffinSSG.render(
layout,
%{
front_matter: frontmatter,
content: content,
assigns: %{partials: partials, title: "Griffin"}
}
)
File.write!(file_path, output)
try do
rescue
MatchError ->
# file parsing failed
Mix.raise("File parsing failed for file #{input_path}")
end
end
defp print_compiled_layouts(num_layouts, num_partials) do
Mix.shell().info(
"Compiled #{num_layouts + num_partials} layouts (#{num_partials} partial#{unless num_partials == 1, do: "s"})"
)
end
defp extension_parser(ext) when ext in [".md", ".markdown"] do
"markdown"
end
defp get_workable_files(input_path, extensions \\ @extensions) do
Path.wildcard("#{input_path}/**/*.*", match_dot: false)
|> Enum.filter(&(not String.starts_with?(&1, ["_"])))
# |> Enum.map(&Path.expand/1)
|> Enum.filter(&(Path.extname(&1) in extensions))
end
defp get_layout_files(extensions \\ [".eex"]) do
Path.wildcard("#{File.cwd!()}/lib/layouts/*.*", match_dot: false)
|> Enum.filter(&(not String.starts_with?(&1, ["_"])))
|> Enum.filter(&(Path.extname(&1) in extensions))
end
defp get_partial_layout_files(extensions \\ [".eex"]) do
Path.wildcard("#{File.cwd!()}/lib/layouts/partials/*.*", match_dot: false)
|> Enum.filter(&(not String.starts_with?(&1, ["_"])))
|> Enum.filter(&(Path.extname(&1) in extensions))
end
defp compile_layout(filepath) do
:ets.insert(
:griffin_build_layouts,
{Path.basename(filepath, ".html.eex"), EEx.compile_file(filepath)}
)
end
defp application_config do
@all_options
|> Enum.map(fn option -> {option, get_app_env(option)} end)
|> Enum.into(%{})
|> Map.filter(fn {_, v} -> not is_nil(v) end)
end
defp environment_config do
@all_options
|> Enum.map(fn option -> {option, get_env(option)} end)
|> Enum.into(%{})
|> Map.filter(fn {_, v} -> not is_nil(v) end)
end
defp get_env(key) do
key
|> Atom.to_string()
|> String.upcase()
|> then(fn key -> "GRIFFIN_" <> key end)
|> System.get_env()
end
defp get_app_env(key) do
Application.get_env(:griffin_ssg, key)
end
defp fallback_html_layout do
"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title><%= @title %></title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<%= @content %>
</body>
</html>
"""
end
end