%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2016-2024 Marc Worrell
%% @doc Main template compiler entry points.
%% @end
%% Copyright 2016-2024 Marc Worrell
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
-module(template_compiler).
-author('Marc Worrell <marc@worrell.nl>').
-export([
render/4,
render/5,
render_block/5,
lookup/3,
flush/0,
flush_file/1,
flush_context_name/1,
compile_file/3,
compile_binary/4,
get_option/2,
is_template_module/1,
translations/1,
compile_blocks/2
]).
-include_lib("syntax_tools/include/merl.hrl").
-include_lib("kernel/include/logger.hrl").
-include("template_compiler_internal.hrl").
-include("template_compiler.hrl").
-type option() :: {runtime, atom()}
| {context_name, term()}
| {trace_position, {Filename::binary(), Line::integer(), Col::integer()}}
| {context_vars, list(binary())}.
-type options() :: list(option()).
-type template_file() :: #template_file{}.
-type template() :: binary()
| string()
| {cat, binary()|string()}
| {cat, binary()|string(), term()}
| {overrules, binary()|string(), file:filename_all()}
| template_file().
-type template_key() :: {ContextName::term(), Runtime::atom(), template()}.
-type render_result() :: binary() | string() | term() | list(render_result()).
-type model_return() :: {ok, {term(), list()}}
| {error, term()}.
-type builtin_tag() :: image
| image_url
| image_data_url
| media
| url
| lib.
-type translation_message() :: {
Text :: binary(),
Args :: proplists:proplist(),
{
Filename :: file:filename(),
Line :: pos_integer(),
Column :: pos_integer()
}
}.
-export_type([
option/0,
options/0,
template/0,
template_file/0,
template_key/0,
builtin_tag/0,
translation_message/0,
render_result/0,
model_return/0
]).
%% @doc Render a template. This looks up the templates needed, ensures compilation and
%% returns the rendering result.
-spec render(Template :: template(), Vars :: map() | list(), Options :: options(), Context :: term()) ->
{ok, render_result()} | {error, term()}.
render(Template0, Vars, Options, Context) ->
render(Template0, #{}, Vars, Options, Context).
%% @doc Render a template. This looks up the templates needed, ensures compilation and
%% returns the rendering result. Start with a block-map to find some predefined blocks.
-spec render(Template :: template(), BlockMap :: map(), Vars :: map() | list(), Options :: options(), Context :: term()) ->
{ok, render_result()} | {error, term()}.
render(Template0, BlockMap0, Vars, Options, Context) when is_list(Vars) ->
render(Template0, BlockMap0, props_to_map(Vars, #{}), Options, Context);
render(Template0, BlockMap0, Vars, Options, Context) when is_map(Vars) ->
Template = normalize_template(Template0),
Runtime = proplists:get_value(runtime, Options, template_compiler_runtime),
case block_lookup(Runtime:map_template(Template, Vars, Context), BlockMap0, [], [], Options, Vars, Runtime, Context) of
{ok, BaseModule, ExtendsStack, BlockMap, OptDebugWrap} ->
% Start with the render function of the "base" template
% Optionally add the unique prefix for this rendering.
Vars1 = case BaseModule:is_autoid()
orelse lists:any(fun(M) -> M:is_autoid() end, ExtendsStack)
of
true ->
Vars#{
'$autoid' => template_compiler_runtime_internal:unique()
};
false ->
Vars
end,
{ok, maybe_wrap(BaseModule:render(Vars1, BlockMap, Context), OptDebugWrap)};
{error, {Loc, template_compiler_parser, S}} ->
{error, {Loc, template_compiler_parser, iolist_to_binary(S)}};
{error, _} = Error ->
Error
end.
%% @doc Render a named block, defined in a template
-spec render_block(Block :: atom(), Template :: template(), Vars :: map() | list(),
Options :: options(), Context :: term()) ->
{ok, render_result()} | {error, term()}.
render_block(Block, Template, Vars, Options, Context) when is_list(Vars) ->
render_block(Block, Template, props_to_map(Vars, #{}), Options, Context);
render_block(Block, Template0, Vars, Options, Context) when is_map(Vars) ->
Template = normalize_template(Template0),
Runtime = proplists:get_value(runtime, Options, template_compiler_runtime),
case block_lookup(Runtime:map_template(Template, Vars, Context), #{}, [], [], Options, Vars, Runtime, Context) of
{ok, BaseModule, ExtendsStack, BlockMap, _OptDebugWrap} ->
% Optionally add the unique prefix for this rendering.
Vars1 = case BaseModule:is_autoid()
orelse lists:any(fun(M) -> M:is_autoid() end, ExtendsStack)
of
true ->
Vars#{
'$autoid' => template_compiler_runtime_internal:unique()
};
false ->
Vars
end,
% Render the specific block
{ok, template_compiler_runtime_internal:block_call({<<>>,1,1}, Block, Vars1, BlockMap, Runtime, Context)};
{error, {Loc, template_compiler_parser, S}} ->
{error, {Loc, template_compiler_parser, iolist_to_binary(S)}};
{error, _} = Error ->
Error
end.
maybe_wrap(RenderResult, []) ->
RenderResult;
maybe_wrap(RenderResult, [ok|Rest]) ->
maybe_wrap(RenderResult, Rest);
maybe_wrap(RenderResult, [{ok, Before, After}|Rest]) ->
maybe_wrap([Before, RenderResult, After], Rest).
props_to_map([], Map) ->
Map;
props_to_map([{K,V}|Rest], Map) ->
props_to_map(Rest, Map#{K => V});
props_to_map([K|Rest], Map) ->
props_to_map(Rest, Map#{K => true}).
%% @doc Map all string() template names to binary().
-spec normalize_template(template()) -> template().
normalize_template(Template) when is_binary(Template) ->
Template;
normalize_template(#template_file{filename=Fn, template=Tpl} = T) when is_binary(Fn), is_binary(Tpl) ->
T;
normalize_template({cat, Template} = T) when is_binary(Template) ->
T;
normalize_template({cat, Template, _} = T) when is_binary(Template) ->
T;
normalize_template({overrules, Template, _Filename} = T) when is_binary(Template) ->
T;
normalize_template(Template) when is_list(Template) ->
unicode:characters_to_binary(Template);
normalize_template(#template_file{filename=Fn, template=Tpl}) ->
#template_file{
filename=unicode:characters_to_binary(Fn),
template=unicode:characters_to_binary(Tpl)
};
normalize_template({cat, Template}) when is_list(Template) ->
{cat, unicode:characters_to_binary(Template)};
normalize_template({cat, Template, IsA}) when is_list(Template) ->
{cat, unicode:characters_to_binary(Template), IsA};
normalize_template({overrules, Template, Filename}) when is_list(Template) ->
{overrules, unicode:characters_to_binary(Template), Filename}.
%% @doc Recursive lookup of blocks via the extends-chain of a template.
block_lookup({ok, TplFile}, BlockMap, ExtendsStack, DebugTrace, Options, Vars, Runtime, Context) ->
TplFilename = TplFile#template_file.filename,
Trace = Runtime:trace_render(TplFile#template_file.filename, Options, Context),
case template_compiler_admin:lookup(TplFile#template_file.filename, Options, Context) of
{ok, Module} ->
case lists:member(Module, ExtendsStack) of
true ->
FileTrace = [Module:filename() | [ M:filename() || M <- ExtendsStack ]],
?LOG_ERROR(#{
text => <<"Template recursion">>,
result => error,
reason => recursion,
trace => FileTrace
}),
{error, {recursion, [Trace|DebugTrace]}};
false ->
% Check extended/overruled templates (build block map)
BlockMap1 = add_blocks(Module:blocks(), Module, BlockMap),
case Module:extends() of
undefined ->
{ok, Module, ExtendsStack, BlockMap1, [Trace|DebugTrace]};
overrules ->
Options1 = [
{trace_position, {TplFilename, 0, 0}}
| lists:keydelete(trace_position, 1, Options)
],
Template = TplFile#template_file.template,
Next = Runtime:map_template({overrules, Template, Module:filename()}, Vars, Context),
block_lookup(Next, BlockMap1, [Module|ExtendsStack], [Trace|DebugTrace], Options1, Vars, Runtime, Context);
Extends when is_binary(Extends) ->
Options1 = [
{trace_position, {TplFilename, 0, 0}}
| lists:keydelete(trace_position, 1, Options)
],
Next = Runtime:map_template(Extends, Vars, Context),
block_lookup(Next, BlockMap1, [Module|ExtendsStack], [Trace|DebugTrace], Options1, Vars, Runtime, Context)
end
end;
{error, _} = Error ->
Error
end;
block_lookup({error, _} = Error, _BlockMap, _ExtendsStack, _DebugTrace, _Options, _Vars, _Runtime, _Context) ->
Error.
add_blocks([], _Module, BlockMap) ->
BlockMap;
add_blocks([Block|Blocks], Module, BlockMap) ->
List = maps:get(Block, BlockMap, []),
BlockMap1 = BlockMap#{ Block => List ++ [Module]},
add_blocks(Blocks, Module, BlockMap1).
%% @doc Extract compiler options and handle possible defaults.
-spec get_option(Option :: atom(), Options :: options()) -> term().
get_option(runtime, Options) ->
proplists:get_value(runtime, Options, template_compiler_runtime);
get_option(context_vars, Options) ->
proplists:get_value(context_vars, Options, []).
%% @doc Find the module of a compiled template, if not yet compiled then
%% compile the template.
-spec lookup(binary(), options(), term()) -> {ok, atom()} | {error, term()}.
lookup(Filename, Options, Context) ->
template_compiler_admin:lookup(Filename, Options, Context).
%% @doc Remove all template lookups, forces recheck.
-spec flush() -> ok.
flush() ->
template_compiler_admin:flush().
%% @doc Ping that a template has been changed
-spec flush_file(file:filename_all()) -> ok.
flush_file(Filename) ->
template_compiler_admin:flush_file(Filename).
%% @doc Ping that a template has been changed
-spec flush_context_name(ContextName::term()) -> ok.
flush_context_name(ContextName) ->
template_compiler_admin:flush_context_name(ContextName).
%% @doc Compile a template to a module. The template is the path of the
%% template to be compiled.
-spec compile_file(file:filename_all(), options(), term()) -> {ok, atom()} | {error, term()}.
compile_file(Filename, Options, Context) ->
case file:read_file(Filename) of
{ok, Tpl} ->
compile_binary(Tpl, Filename, Options, Context);
{error, _} = Error ->
Error
end.
%% @doc Compile a in-memory template to a module.
-spec compile_binary(binary(), file:filename_all(), options(), term()) -> {ok, atom()} | {error, term()}.
compile_binary(Tpl, Filename, Options, Context) when is_binary(Tpl) ->
Mtime = template_compiler_utils:file_mtime(Filename),
case template_compiler_scanner:scan(Filename, Tpl) of
{ok, Tokens} ->
Runtime = get_option(runtime, Options),
ContextVars = get_option(context_vars, Options),
Tokens1 = maybe_drop_text(Tokens, Tokens),
Tokens2 = expand_translations(Tokens1, Runtime, Context),
Module = module_name(Runtime, Filename, ContextVars, Tokens2),
case erlang:module_loaded(Module) of
true ->
{ok, Module};
false ->
Runtime:trace_compile(Module, Filename, Options, Context),
case compile_tokens(
template_compiler_parser:parse(Tokens2),
cs(Module, Filename, Options, Context),
Options)
of
{ok, {Extends, Includes, BlockAsts, TemplateAst, IsAutoid}} ->
Forms = template_compiler_module:compile(
Module, Filename, Mtime, IsAutoid, Runtime,
Extends, Includes, BlockAsts, TemplateAst),
compile_forms(Filename, Forms);
{error, _} = Error ->
Error
end
end;
{error, _} = Error ->
Error
end.
%% @doc Check if the modulename looks like a module generated by the template compiler.
-spec is_template_module(binary()|string()|atom()) -> boolean().
is_template_module(<<"tpl_", _/binary>>) -> true;
is_template_module("tpl_" ++ _) -> true;
is_template_module(X) when is_binary(X) -> false;
is_template_module(X) when is_list(X) -> false;
is_template_module(Name) -> is_template_module(z_convert:to_binary(Name)).
%% @doc Fetch all translatable strings from a template.
-spec translations(file:filename_all()) -> {ok, [translation_message()]} | {error, term()}.
translations(Filename) ->
case file:read_file(Filename) of
{ok, Bin} ->
case template_compiler_scanner:scan(Filename, Bin) of
{ok, Tokens} ->
Tokens1 = map_text_tokens(Tokens, []),
{ok, extract_translations(Tokens1)};
{error, _} = Error ->
Error
end;
{error, _} = Error ->
Error
end.
%%%% --------------------------------- Internal ----------------------------------
module_name(Runtime, Filename, SpecialContextArgs, Tokens) ->
Term = {
?COMPILER_VERSION,
unicode:characters_to_binary(Filename),
Runtime,
SpecialContextArgs,
remove_srcpos(Tokens)
},
TokenChecksum = crypto:hash(sha, term_to_binary(Term)),
Hex = z_string:to_lower(z_url:hex_encode(TokenChecksum)),
binary_to_atom(iolist_to_binary(["tpl_",Hex]), 'utf8').
% Ensure that duplicate files have the same checksum by removing the filename.
remove_srcpos(Tokens) ->
[ {Token, V} || {Token, _SrcPos, V} <- Tokens ].
compile_forms(Filename, Forms) ->
% case compile:forms(Forms, [nowarn_shadow_vars]) of
Forms1 = [ erl_syntax:revert(Form) || Form <- Forms ],
case compile:forms(Forms1, [report_errors]) of
Compiled when element(1, Compiled) =:= ok ->
[ok, Module, Bin | _Info] = tuple_to_list(Compiled),
code:purge(Module),
case code:load_binary(Module, atom_to_list(Module) ++ ".erl", Bin) of
{module, _Module} ->
{ok, Module};
{error, Reason} = Error ->
?LOG_ERROR(#{
text => <<"Error loading compiling forms">>,
module => Module,
filename => Filename,
result => error,
reason => Reason
}),
Error
end;
error ->
?LOG_ERROR(#{
text => <<"Error compiling forms">>,
result => error,
reason => compile,
filename => Filename
}),
{error, {compile, []}};
{error, Es, Ws} ->
?LOG_ERROR(#{
text => <<"Errors compiling">>,
filename => Filename,
result => error,
reason => compile,
errors => Es,
warnings => Ws
}),
{error, {compile, Es, Ws}}
end.
cs(Module, Filename, Options, Context) ->
#cs{
filename=Filename,
module=Module,
runtime=get_option(runtime, Options),
context_vars=get_option(context_vars, Options),
context=Context
}.
compile_tokens({ok, {extends, {string_literal, _, Extend}, Elements}}, CState, _Options) ->
case find_blocks(Elements) of
{ok, Blocks} ->
{Ws, BlockAsts} = compile_blocks(Blocks, CState),
{ok, {Extend, Ws#ws.includes, BlockAsts, undefined, Ws#ws.is_autoid_var}};
{error, _} = Error ->
Error
end;
compile_tokens({ok, {overrules, Elements}}, CState, _Options) ->
case find_blocks(Elements) of
{ok, Blocks} ->
{Ws, BlockAsts} = compile_blocks(Blocks, CState),
{ok, {overrules, Ws#ws.includes, BlockAsts, undefined, Ws#ws.is_autoid_var}};
{error, _} = Error ->
Error
end;
compile_tokens({ok, {base, Elements}}, CState, _Options) ->
case find_blocks(Elements) of
{ok, Blocks} ->
{Ws, BlockAsts} = compile_blocks(Blocks, CState),
CStateElts = CState#cs{blocks = BlockAsts},
{Ws1, TemplateAsts} = template_compiler_element:compile(Elements, CStateElts, Ws),
{ok, {undefined, Ws1#ws.includes, BlockAsts, TemplateAsts, Ws1#ws.is_autoid_var}};
{error, _} = Error ->
Error
end;
compile_tokens({error, {Loc, template_compiler_parser, Msg}}, #cs{ filename = Filename }, Options) ->
% Try format the Yecc error
Err = split_loc(Loc),
Stack = [
{filename:basename(Filename), render, 1, [
{file, maps:get(at, Err)},
{line, {maps:get(line, Err), maps:get(column, Err)}}
]}
| case proplists:get_value(trace_position, Options) of
{SrcFile, SrcLine, _SrcCol} ->
[
{filename:basename(SrcFile), render, 1, [
{file, SrcFile},
{line, SrcLine}
]}
];
undefined ->
[]
end
],
Err1 = Err#{
result => error,
reason => syntax,
text => iolist_to_binary(Msg),
stack => Stack
},
{error, Err1};
compile_tokens({error, _} = Error, _CState, _Options) ->
Error.
split_loc({Filename, Line, Col}) ->
#{
at => Filename,
line => Line,
column => Col
};
split_loc({Filename, Line}) ->
#{
at => Filename,
line => Line
}.
-spec compile_blocks([block_element()], #cs{}) -> {#ws{}, [{atom(), erl_syntax:syntaxTree(), #ws{}}]}.
compile_blocks(Blocks, CState) ->
Ws = #ws{},
lists:foldl(
fun(Block, {WsAcc, BlockAcc}) ->
CState1 = CState#cs{blocks = BlockAcc},
{WsAcc1, B} = compile_block(Block, CState1, WsAcc),
{WsAcc1, [B|BlockAcc]}
end,
{Ws,[]},
Blocks).
%% @doc Compile a block definition to a function name and its body elements.
-spec compile_block(block_element(), #cs{}, #ws{}) -> {#ws{}, {atom(), erl_syntax:syntaxTree(), #ws{}}}.
compile_block({block, {identifier, _Pos, Name}, Elts}, CState, Ws) ->
BlockName = template_compiler_utils:to_atom(Name),
{Ws1, Body} = template_compiler_element:compile(Elts, CState#cs{block=BlockName}, reset_block_ws(Ws)),
{Ws1, {BlockName, Body, Ws1}}.
reset_block_ws(Ws) ->
Ws#ws{is_forloop_var=false}.
%% @doc Extract all block definitions from the parse tree, returns deepest nested blocks first
find_blocks(Elements) ->
find_blocks(Elements, [], []).
find_blocks([], Acc, _Stack) ->
{ok, Acc};
find_blocks([B|Bs], Acc, Stack) ->
case find_blocks(B, Acc, Stack) of
{ok, Acc1} ->
find_blocks(Bs, Acc1, Stack);
{error, _} = Error ->
Error
end;
find_blocks({block, {identifier, _Pos, Name}, Elements} = Block, Acc, Stack) ->
case lists:member(Name, Stack) of
true ->
{error, {duplicate_nested_block, Name}};
false ->
Acc1 = [ Block | Acc ],
Stack1 = [ Name | Stack ],
find_blocks(Elements, Acc1, Stack1)
end;
find_blocks(Element, Acc, Stack) ->
find_blocks(block_elements(Element), Acc, Stack).
block_elements({for, _, Loop, Empty}) -> [Loop,Empty];
block_elements({'if', _, If, Else}) -> [If, Else];
block_elements({spaceless, Elts}) -> Elts;
block_elements({autoescape, _, Elts}) -> Elts;
block_elements({with, _, Elts}) -> Elts;
block_elements({cache, _, Elts}) -> Elts;
block_elements({javascript, Elts}) -> Elts;
block_elements({filter, _, Elts}) -> Elts;
block_elements(_) -> [].
%% @doc Optionally drop text before {% extends %} or {% overrules %}.
maybe_drop_text([{text, _SrcRef, _Text}|Rest], OrgTks) ->
maybe_drop_text(Rest, OrgTks);
maybe_drop_text([{comment, _Text}|Rest], OrgTks) ->
maybe_drop_text(Rest, OrgTks);
maybe_drop_text([{open_tag, _, _}, {extends_keyword, _, _}|_] = Tks, _OrgTks) ->
Tks;
maybe_drop_text([{open_tag, _, _}, {overrules_keyword, _, _}|_] = Tks, _OrgTks) ->
Tks;
maybe_drop_text(_, [{open_tag, SrcRef, _}|_] = OrgTks) ->
[{text, SrcRef, <<>>}|OrgTks];
maybe_drop_text(_, OrgTks) ->
OrgTks.
%% @doc Expand all translations in the tokens. Translations are always looked up at compile time.
expand_translations(Tokens, Runtime, Context) ->
Tokens1 = map_text_tokens(Tokens, []),
[ expand_translation(Token, Runtime, Context) || Token <- Tokens1 ].
map_text_tokens([], Acc) ->
lists:reverse(Acc);
map_text_tokens([{trans_keyword, _, _}=Trans, {string_literal, SrcPos, Text}|Ts], Acc) ->
Acc1 = [{trans_literal, SrcPos, unescape_trim(Text)},Trans|Acc],
map_text_tokens(Ts, Acc1);
map_text_tokens([{trans_text, SrcPos, Text}|Ts], Acc) ->
Acc1 = [{trans_text, SrcPos, unescape_trim(Text)}|Acc],
map_text_tokens(Ts, Acc1);
map_text_tokens([{trans_literal, SrcPos, Text}|Ts], Acc) ->
Acc1 = [{trans_literal, SrcPos, unescape_trim(Text)}|Acc],
map_text_tokens(Ts, Acc1);
map_text_tokens([{string_literal, SrcPos, Text}|Ts], Acc) ->
Acc1 = [{string_literal, SrcPos, template_compiler_utils:unescape_string_literal(Text)}|Acc],
map_text_tokens(Ts, Acc1);
map_text_tokens([T|Ts], Acc) ->
map_text_tokens(Ts, [T|Acc]).
unescape_trim(Text) ->
Unescaped = template_compiler_utils:unescape_string_literal(Text),
z_string:trim(Unescaped).
expand_translation({trans_text, SrcPos, Text}, Runtime, Context) ->
case Runtime:get_translations(Text, Context) of
{trans, _} = Tr -> {trans_text, SrcPos, Tr};
B when is_binary(B) -> {text, SrcPos, B}
end;
expand_translation({trans_literal, SrcPos, Text}, Runtime, Context) ->
case Runtime:get_translations(Text, Context) of
{trans, _} = Tr -> {trans_literal, SrcPos, Tr};
B when is_binary(B) -> {trans_literal, SrcPos, {trans, [{en, B}]}}
end;
expand_translation(Token, _Runtime, _Context) ->
Token.
%% @doc Fetch all translatable strings from the tokens
extract_translations(Tokens) ->
lists:foldl(
fun
({trans_text, LineAndNumber, Text}, Acc) ->
[ {Text, [], LineAndNumber} | Acc ];
({trans_literal, LineAndNumber, Text}, Acc) ->
[ {Text, [], LineAndNumber} | Acc ];
(_Token, Acc) ->
Acc
end,
[],
Tokens).