defmodule Dragon.Template.Evaluate do
use Dragon.Context
import Dragon.Template.Env, only: [get_file_metadata: 4]
import Dragon.Data, only: [clean_data: 1]
import Dragon.Template.Read
import Dragon.Tools.File
# Todo: after moved to Transmogrify remove import
import Transmogrify.As
@moduledoc """
Core heart of evaluating EEX Templates.
Although Dragon is a standalone genserver for its data, we still try to push
the Dragon struct on the current processes' stack to keep data movement to
a minimum, and bring it in again with Dragon.get() only when we've lost the
context (such as when called from within a template's helper functions).
"""
def all(%Dragon{files: %{dragon: l}} = d), do: all(d, Map.keys(l) |> Enum.sort())
def all(%Dragon{} = d, [file | rest]) do
with {:ok, path} <- find_file(d.root, file) do
read_template_header(path)
|> evaluate(:primary, d, %{})
|> validate()
|> commit_file(d.root)
all(d, rest)
end
end
def all(%Dragon{} = d, _), do: {:ok, d}
##############################################################################
def processing(file, layout \\ nil)
def processing(file, nil), do: stdout([:green, "EEX Template ", :reset, :bright, file])
def processing(file, layout),
do:
stdout([:green, "EEX Template ", :reset, :bright, file, :reset, :light_blue, " (#{layout})"])
##############################################################################
# process files with a layout directive slightly differently. First, process
# the current file and get the output results. Then call, as an include,
# the layout template, sending the current output into that as a page
# argument (@page.content)
defp origin_frame(d, path, layout, func) do
with_frame(
fn
nil ->
%{origin: path, prev: nil, this: nil, page: nil}
_existing ->
raise Dragon.AbortError,
message: "Starting new execution frame but an existing one still exists!"
end,
fn frame ->
processing(drop_root(d.root, path), layout)
func.(frame)
end
)
end
def evaluate(
{:ok, %{"@spec": %{layout: layout}} = headers, path, offset, _},
:primary,
%Dragon{} = d,
args
)
when is_map(args) do
origin_frame(d, path, layout, fn _ ->
with {:ok, target, headers, output} <-
evaluate_frame(path, offset, headers, d, args),
# then insert into layout as an include
{:ok, _, _, output} <-
Enum.map(d.layouts, &Path.join(&1, layout))
|> include_first_file({d, [content: output], headers}),
do: {:ok, target, headers, output}
end)
end
def evaluate({:ok, headers, path, offset, _}, :primary, %Dragon{} = d, args)
when is_map(args),
do:
origin_frame(d, path, nil, fn _ ->
evaluate_frame(path, offset, headers, d, args)
end)
def evaluate({:ok, headers, path, offset, _}, :layout, %Dragon{} = d, args) when is_map(args),
do: evaluate_frame(path, offset, headers, d, args)
def evaluate({:error, reason}, _, _, _), do: abort("Unable to continue: #{reason}")
defp evaluate_frame(path, offset, headers, %Dragon{} = d, args) do
headers = clean_data(headers)
with {:ok, content} <- read_template_body(path, offset),
{:ok, page} <- get_file_metadata(d.root, path, headers, args),
env <- Map.merge(d.data, %{dragon: d, page: page}),
{:ok, output} <- evaluate_template(d, path, content, env),
do: posteval(d, headers, path, output)
end
################################################################################
defp include_first_file([path | rest], {d, _, _} = args) do
case find_file(d.root, path) do
{:ok, target} ->
include_file_inner(target, args)
{:error, msg} ->
case rest do
[] ->
raise ArgumentError, message: "Include failed: #{msg}"
_ ->
include_first_file(rest, args)
end
end
end
defp include_first_file([], _),
do: raise(ArgumentError, message: "Include failed, paths exhausted")
##############################################################################
def include_file(x, d, unknown, args, page \\ nil)
def include_file(paths, %Dragon{} = d, _, args, page) when is_list(paths),
do: include_first_file(paths, {d, args, page})
def include_file(path, %Dragon{} = d, _, args, page),
do: include_first_file([path], {d, args, page})
##############################################################################
# we don't pay attention to layout here
defp include_file_inner(target, {d, args, page}) do
stderr([:light_black, "+ Including #{drop_root(d.root, target)}"])
inputs =
read_template_header(target)
|> handle_non_template(target)
parent_page =
if is_nil(page) do
Map.get(Dragon.frame_head() || %{}, :page) || %{}
else
page
end
args =
check_include_args(inputs, Map.new(args))
|> Map.put(:page, parent_page)
evaluate(inputs, :layout, d, args)
end
defp handle_non_template({:error, _}, target), do: {:ok, %{}, target, 0, 0}
defp handle_non_template({:ok, _, _, _, _} = pass, _), do: pass
defp check_include_args({:ok, %{"@spec": %{args: argref}}, _, _, _}, args) do
Enum.map(argref, fn
"?" <> k ->
{:optional, as_key(k)}
k when is_binary(k) ->
{:required, as_key(k)}
m when is_map(m) ->
case Map.to_list(m) do
[{k, v}] -> {:default, as_key(k), v}
value -> raise ArgumentError, message: "invalid @spec.args #{inspect(value)}"
end
other ->
raise ArgumentError, message: "invalid @spec.args #{inspect(other)}"
end)
|> Enum.reduce(args, fn spec, args ->
case spec do
{:required, key} when is_map_key(args, key) ->
args
{:required, key} ->
raise ArgumentError, message: "Include missing arg: #{key}"
{:default, key, value} when is_map_key(args, key) ->
if not is_nil(args[key]), do: args, else: Map.put(args, key, value)
{:default, key, value} ->
Map.put(args, key, value)
{:optional, _} ->
args
end
end)
end
defp check_include_args(_, args), do: args
################################################################################
def validate({:ok, dst, headers, content}) do
# future: scan html content for breaks
{:ok, dst, headers, content}
end
##############################################################################
def posteval(%{root: root, build: build} = d, headers, origin, content) do
target = Path.join(build, Dragon.Tools.File.drop_root(root, origin))
Dragon.Plugin.posteval(d, origin, target, headers, content)
end
##############################################################################
def commit_file({:ok, path, headers, content}, root) do
stderr([:light_black, "✓ Saving ", :reset, drop_root(root, path)])
file =
case headers do
%{"@spec": %{output: "folder/index"}} -> Path.join(Path.rootname(path), "index.html")
_ -> path
end
Dragon.Tools.File.write_file(file, content)
end
# side-effect execution frame state management
defp with_frame(update, inner) do
try do
update.(Dragon.frame_head()) |> Dragon.frame_push() |> inner.()
after
Dragon.frame_pop()
end
end
##############################################################################
def evaluate_template(%Dragon{imports: imports}, path, template, env) do
with_frame(
fn
nil ->
raise Dragon.AbortError, message: "Mid-frame execution without parent?"
frame ->
%{
frame
| prev: Map.get(frame, :this),
this: path,
# drop content—that shouldn't go into frame state
page: (Map.get(env, :page) || %{}) |> Map.delete(:content)
}
end,
fn frame ->
exec_frame(frame, imports, path, template, env)
end
)
end
def error_message(%{message: msg}) when not is_nil(msg), do: msg
def error_message(err), do: inspect(err)
def nofile_line([{:elixir_eval, :__FILE__, 1, [file: 'nofile', line: line]} | _]), do: line
def nofile_line([_ | rest]), do: nofile_line(rest)
def nofile_line([]), do: 0
# sort | reverse
defp exec_frame(frame, imports, path, template, env) do
try do
env = Map.put(env, :frame, frame)
{:ok, EEx.eval_string(imports <> template, assigns: Map.to_list(env))}
rescue
err ->
case err do
## TODO: include offset in line count so you can find it in the editor!
%{file: "nofile", line: line, description: msg} ->
# minus one to the line because we added a line above
abort_nofile_error(template, path, line - 1, msg)
%KeyError{key: key, term: data} ->
abort_nofile_error(
template,
path,
__STACKTRACE__,
"key #{inspect(key)} not found in: #{inspect(data)}"
)
_ ->
nofile_error(template, path, __STACKTRACE__, err)
Kernel.reraise(err, __STACKTRACE__)
end
end
end
@dialyzer {:nowarn_function, [abort_nofile_error: 4]}
def abort_nofile_error(a, b, c, d) do
nofile_error(a, b, c, d)
abort("Cannot continue")
end
def nofile_error(template, path, lineno, msg) when is_integer(lineno) and is_binary(msg) do
header_lines =
case read_template_header(path) do
{:ok, _, _, _, lines} -> lines + 2
_ -> 0
end
first = lineno - 2
first = if first < 0, do: 0, else: first
last = lineno + 2
stderr(["\n", :yellow, "? ", "#{path}:#{lineno}", :reset, " — ", :yellow, :bright, msg, "\n"])
String.split(template, "\n")
|> Enum.reduce_while(1, fn line, index ->
cond do
index == lineno -> print_with_line("»", index + header_lines, line)
index > first and index < last -> print_with_line(" ", index + header_lines, line)
true -> :ok
end
if index == last do
{:halt, index}
else
{:cont, index + 1}
end
end)
IO.puts(:stderr, "\n")
end
def nofile_error(t, p, l, m) when not is_binary(m), do: nofile_error(t, p, l, error_message(m))
def nofile_error(t, p, tb, m) when is_list(tb), do: nofile_error(t, p, nofile_line(tb), m)
defp print_with_line(prefix, index, line) do
padded = String.pad_leading("#{index}", 3)
IO.puts(:stderr, IO.ANSI.format([:blue, :bright, "#{prefix}#{padded}: ", :reset, line]))
end
end