# Copyright(c) 2015-2023 ACCESS CO., LTD. All rights reserved.
use Croma
defmodule Antikythera.TemplatePrecompiler do
@moduledoc """
Definition of macro to precompile HAML templates.
Each gear's template module (web/template.ex) must use this module as follows:
defmodule YourGear.Template do
use Antikythera.TemplatePrecompiler
end
HAML files whose paths match `web/template/**/*.html.haml` are loaded and converted into function clauses at compile time.
To render HAML files in controller actions, use `Antikythera.Conn.render/5`.
As all macro-generated function clauses of `content_for/2` reside in `YourGear.Template`,
you can, for example, put `alias` before `use Antikythera.TemplatePrecompiler` so that it takes effect in all HAML templates.
"""
alias Antikythera.MacroUtil
alias AntikytheraCore.TemplateEngine
defmacro __using__(_) do
%Macro.Env{file: caller_filepath, module: module} = __CALLER__
check_caller_module(module)
haml_content_funs = Path.dirname(caller_filepath) |> define_haml_content_funs()
[define_mix_recompile_fun() | haml_content_funs]
end
defp check_caller_module(mod) do
gear_name_camel = Mix.Project.config()[:app] |> Atom.to_string() |> Macro.camelize()
case Module.split(mod) do
[^gear_name_camel, "Template"] -> :ok
_ -> raise "`use #{inspect(__MODULE__)}` is usable only in `#{gear_name_camel}.Template`"
end
end
defp define_haml_content_funs(dir) do
haml_paths = Path.wildcard(Path.join([dir, "template", "**", "*.html.haml"]))
if Enum.empty?(haml_paths) do
[]
else
clause_header =
quote do
@spec content_for(String.t(), Keyword.t(any)) :: {:safe, String.t()}
def content_for(name, params \\ [])
end
clauses = Enum.map(haml_paths, &define_haml_content_fun(dir, &1))
[clause_header | clauses]
end
end
defp define_haml_content_fun(dir, path) do
name =
path
|> Path.relative_to(Path.join(dir, "template"))
|> String.replace_suffix(".html.haml", "")
eex_content = path |> File.read!() |> Calliope.Render.precompile()
quoted_content = EEx.compile_string(eex_content, file: path, engine: TemplateEngine)
var_names = extract_free_vars_in_quoted(quoted_content)
quote do
# file modifications are tracked by `PropagateFileModifications`; `@external_resource` here would be redundant
def content_for(unquote(name), params) do
import Antikythera.TemplateSanitizer
unquote(Enum.map(var_names, &Macro.var(&1, nil))) =
Enum.map(unquote(var_names), &Keyword.fetch!(params, &1))
unquote(quoted_content)
end
end
end
defp extract_free_vars_in_quoted(q) do
{free_vars_map, _} =
MacroUtil.prewalk_accumulate(q, {%{}, MapSet.new()}, &extract_vars_in_ast_node/2)
for {var_name, count} <- free_vars_map, count > 0, do: var_name
end
defp extract_vars_in_ast_node(t, {acc_free, acc_bound} = acc) do
case t do
# cancel count of type expr in AST of string interpolation
{:"::", _, [_, {:binary, _, nil}]} ->
{Map.update(acc_free, :binary, -1, &(&1 - 1)), acc_bound}
{:=, _, [lhs, _rhs]} ->
{acc_free, MapSet.union(collect_vars(lhs), acc_bound)}
{:<-, _, [lhs, _rhs]} ->
{acc_free, MapSet.union(collect_vars(lhs), acc_bound)}
{:->, _, [[lhs | _guards], _rhs]} ->
{acc_free, MapSet.union(collect_vars(lhs), acc_bound)}
{name, _, nil} ->
if name in acc_bound, do: acc, else: {Map.update(acc_free, name, 1, &(&1 + 1)), acc_bound}
_ ->
acc
end
end
defunp collect_vars(q :: Macro.t()) :: MapSet.t() do
MacroUtil.prewalk_accumulate(q, [], fn t, acc ->
case t do
{name, _, nil} when is_atom(name) -> [name | acc]
_ -> acc
end
end)
|> MapSet.new()
end
defp define_mix_recompile_fun() do
quote do
@last_modified File.stat!(__ENV__.file) |> Map.get(:mtime)
def __mix_recompile__?() do
File.stat!(__ENV__.file) |> Map.get(:mtime) > @last_modified
end
end
end
end