src/support/z_template.erl

%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2009-2026 Marc Worrell
%% @doc Template handling, compiles and renders django compatible templates using the
%% template_compiler.
%% @end

%% Copyright 2009-2026 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(z_template).
-author("Marc Worrell <marc@worrell.nl>").

-export([start_link/1]).

%% External exports
-export([
    lookup/2,
    enable_debug_points/3,
    disable_debug_points/1,

    reset/1,
    module_reindexed/2,
    render/2,
    render/3,
    render_to_iolist/3,

    render_block/3,
    render_block/4,
    render_block_to_iolist/4,

    is_template_module/1,
    template_module/3,
    blocks/3,
    includes/3,
    extends/3
]).

-include_lib("template_compiler/include/template_compiler.hrl").
-include("../../include/zotonic.hrl").


start_link(Site) ->
    Context = z_context:new(Site),
    z_context:logger_md(Context),
    z_notifier:observe(module_reindexed, {?MODULE, module_reindexed}, Context),
    ignore.

%% @doc Lookup the module for a compiled template. This will compile the template if it was not
%% yet compiled.
-spec lookup(Filename, Context) -> {ok, Module} | {error, term()} when
    Filename :: binary() | string(),
    Context :: z:context(),
    Module :: module().
lookup(Filename, Context) ->
    Filename1 = unicode:characters_to_binary(Filename),
    Options = template_opts(Context),
    case template_compiler:lookup(Filename1, Options, Context) of
        {ok, Module} ->
            {ok, Module};
        {error, _} ->
            template_compiler:compile_file(Filename1, Options, Context)
    end.

%% @doc Enable debug points for the given template file. This will compile the template and
%% return the module, or an error if it fails.
-spec enable_debug_points(Filename, DebugPoints, Context) -> {ok, Module} | {error, Reason} when
    Filename :: binary() | string(),
    DebugPoints :: [ {Line, Col} ],
    Line :: integer(),
    Col :: integer(),
    Context :: z:context(),
    Module :: module(),
    Reason :: term().
enable_debug_points(Filename, DebugPoints, Context) ->
    Filename1 = unicode:characters_to_binary(Filename),
    Options = [
        {debug_points, DebugPoints}
        | template_opts(Context)
    ],
    case template_compiler:compile_file(Filename1, Options, Context) of
        {ok, Module} ->
            ?LOG_INFO(#{
                in => zotonic_core,
                text => <<"Setting debug points for template">>,
                template => Filename1,
                debug_points => DebugPoints,
                context => z_context:site(Context),
                module => Module
            }),
            {ok, Module};
        {error, Reason} ->
            ?LOG_ERROR(#{
                in => zotonic_core,
                text => <<"Error setting debug points for template">>,
                template => Filename1,
                debug_points => DebugPoints,
                context => z_context:site(Context),
                reason => Reason
            }),
            {error, Reason}
    end.

%% @doc Disable debug points for the given context, this will flush all debug points for the context.
-spec disable_debug_points(Context) -> ok when
    Context :: z:context().
disable_debug_points(Context) ->
    ?LOG_INFO(#{
        in => zotonic_core,
        text => <<"Disabling debug points for context">>,
        context => z_context:site(Context)
    }),
    template_compiler:flush_debug(z_context:site(Context)).

%% @doc Force a reset of all templates, used after a module has been activated or deactivated.
-spec reset(atom()|#context{}) -> ok.
reset(Site) when is_atom(Site) ->
    z_file_mtime:flush_site(Site),
    template_compiler:flush_context_name(Site);
reset(Context) ->
    reset(z_context:site(Context)).

%% @doc Observer, triggered when there are new module files indexed
-spec module_reindexed(module_reindexed, #context{}) -> ok.
module_reindexed(module_reindexed, Context) ->
    reset(z_context:site(Context)).

-spec render(#render{}, #context{}) -> template_compiler:render_result().
render(#render{template=Template, vars=Vars}, Context) ->
    render_block(undefined, Template, Vars, Context).

-spec render(template_compiler:template()|#module_index{}, list()|map(), #context{}) -> template_compiler:render_result().
render(Template, Vars, Context) ->
    render_block(undefined, Template, Vars, Context).

-spec render_block(atom(), #render{}, #context{}) -> template_compiler:render_result().
render_block(Block, #render{template=Template, vars=Vars}, Context) ->
    render_block(Block, Template, Vars, Context).

-spec render_block(atom(), template_compiler:template()|#module_index{}, list()|map(), #context{}) -> template_compiler:render_result().
render_block(OptBlock, Template, Vars, Context) when is_list(Vars) ->
    render_block(OptBlock, Template, props_to_map(Vars, #{}), Context);
render_block(OptBlock, #module_index{filepath=Filename, key=Key}, Vars, Context) ->
    Template = #template_file{
        filename=Filename,
        template=Key#module_index_key.name
    },
    render_block(OptBlock, Template, Vars, Context);
render_block(OptBlock, Template, Vars, Context) when is_map(Vars) ->
    OldCaching = z_depcache:in_process(true),
    Opts = template_opts(Context),
    Context1 = maybe_set_debug_context(Context),
    Result = case OptBlock of
                undefined ->
                    template_compiler:render(Template, Vars, Opts, Context1);
                Block when is_atom(Block) ->
                    template_compiler:render_block(Block, Template, Vars, Opts, Context1)
             end,
    z_depcache:in_process(OldCaching),
    case Result of
        {ok, Output} ->
            Output;
        {error, Reason} when is_list(Reason); is_binary(Reason) ->
            try
                Reason1 = iolist_to_binary(Reason),
                ?LOG_ERROR(#{
                    text => <<"Error rendering template">>,
                    in => zotonic_core,
                    template => Template,
                    block => OptBlock,
                    result => error,
                    reason => Reason1
                })
            catch
                _:_ ->
                    ?LOG_ERROR(#{
                        text => <<"Error rendering template">>,
                        in => zotonic_core,
                        template => Template,
                        block => OptBlock,
                        result => error,
                        reason => Reason
                    })
            end,
            <<>>;
        {error, Reason} when is_map(Reason) ->
            ?LOG_ERROR(Reason),
            <<>>;
        {error, Reason} ->
            ?LOG_ERROR(#{
                text => <<"Error rendering template">>,
                in => zotonic_core,
                template => Template,
                block => OptBlock,
                result => error,
                reason => Reason
            }),
            <<>>
    end.

props_to_map(L, Map) ->
    lists:foldr(
        fun
            ({K, V}, Acc) ->
                Acc#{ K => V };
            (K, Acc) ->
                Acc#{ K => true }
        end,
        Map,
        L).

%% @doc Render a template to an iolist().  This removes all scomp state etc from the rendered html and appends the
%% information in the scomp states to the context for later rendering.
-spec render_to_iolist(template_compiler:template() | #module_index{},
    list() | map(), z:context()) -> {iolist(), z:context()}.
render_to_iolist(File, Vars, Context) ->
    Html = render(File, Vars, Context),
    z_render:render_to_iolist(Html, Context).

%% @doc Render a block template to an iolist().
-spec render_block_to_iolist(atom(), template_compiler:template(), list()|map(), z:context()) ->
    {iolist(), z:context()}.
render_block_to_iolist(Block, File, Vars, Context) ->
    Html = render_block(Block, File, Vars, Context),
    z_render:render_to_iolist(Html, maybe_set_debug_context(Context)).

%% @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(Module) ->
    template_compiler:is_template_module(Module).


%% @doc Return the module of a compiled template. This will compile the template if it was not
%% yet compiled. Vars is needed for catinclude expands.
-spec template_module(Template, Vars, Context) -> {ok, Module} | {error, term()} when
    Template :: template_compiler:template() | #module_index{},
    Vars :: list() | map(),
    Context :: z:context(),
    Module :: module().
template_module(#module_index{filepath=Filename, key=Key}, Vars, Context) ->
    Template = #template_file{
        filename=Filename,
        template=Key#module_index_key.name
    },
    template_module(Template, Vars, Context);
template_module(#template_file{ filename = Filename }, Vars, Context) when is_map(Vars) ->
    template_compiler:lookup(Filename, template_opts(Context), Context);
template_module(Template, Vars, Context) ->
    case z_template_compiler_runtime:map_template(Template, Vars, Context) of
        {ok, MappedTemplate} ->
            template_module(MappedTemplate, Vars, Context);
        {error, _} = Error ->
            Error
    end.

template_opts(Context) ->
    [
        {runtime, z_template_compiler_runtime},
        {context_name, z_context:site(Context)},
        {context_vars, [
            <<"sudo">>,
            <<"anondo">>,
            <<"z_language">>,
            <<"extra_args">>
        ]}
    ].

%% @doc Cache debug flags in the render context. This speeds up the runtime when
%% debug options are not set and there is no debug notification observer.
maybe_set_debug_context(Context) ->
    IsTraceRender = case z_notifier:get_observers(#debug{}, Context) of
        [] -> m_config:get_boolean(mod_development, debug_includes, Context);
        [_|_] -> true
    end,
    if
        IsTraceRender ->
            z_context:set(is_trace_render, true, Context);
        true ->
            Context
    end.

%% @doc Return the list of all block names in a template. This only returns the list
%% in the current template and not in the extended or overruled templates.  Vars is
%% needed for catinclude expands.
-spec blocks(Template, Vars, Context) -> {ok, Blocks} | {error, term()} when
    Template :: template_compiler:template() | #module_index{},
    Vars :: list() | map(),
    Context :: z:context(),
    Blocks :: list( atom() ).
blocks(Template, Vars, Context) ->
    case template_module(Template, Vars, Context) of
        {ok, Module} ->
            {ok, Module:blocks()};
        {error, _} = Error ->
            Error
    end.

%% @doc Return the list of all includes with a fixed template string in a template.
%% Vars is needed for catinclude expands.
-spec includes(Template, Vars, Context) -> {ok, Includes} | {error, term()} when
    Template :: template_compiler:template() | #module_index{},
    Vars :: list() | map(),
    Context :: z:context(),
    Includes :: list( map() ).
includes(Template, Vars, Context) ->
    case template_module(Template, Vars, Context) of
        {ok, Module} ->
            {ok, Module:includes()};
        {error, _} = Error ->
            Error
    end.

%% @doc Return the template that the given template extends or overrules.
-spec extends(Template, Vars, Context) -> {ok, Extends} | {error, term()} when
    Template :: template_compiler:template() | #module_index{},
    Vars :: list() | map(),
    Context :: z:context(),
    Extends :: undefined | binary() | overrules.
extends(Template, Vars, Context) ->
    case template_module(Template, Vars, Context) of
        {ok, Module} ->
            {ok, Module:extends()};
        {error, _} = Error ->
            Error
    end.