# 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