Current section

Files

Jump to
zotonic_mod_development src support z_development_template_graph.erl
Raw

src/support/z_development_template_graph.erl

%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2023-2026 Marc Worrell
%% @doc Calculate the dependency graph between all templates.
%% @end
%% Copyright 2023-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_development_template_graph).
-export([
dot/1,
dot_from_graph/1,
graph/1,
filename_to_node/2
]).
-include_lib("zotonic_core/include/zotonic.hrl").
-type template_node() :: #{
id := binary(),
template := binary(),
module := binary(),
basename := binary(),
filepath := binary(),
index => #module_index{}
}.
-type template_edge() :: #{
from := binary(),
to := binary(),
module := binary(),
type => extends | overrules | include
}.
-export_type([
template_node/0,
template_edge/0
]).
-spec dot(Context) -> {ok, DotFile} when
Context :: z:context(),
DotFile :: binary().
dot(Context) ->
{ok, G} = graph(Context),
dot_from_graph(G).
-spec dot_from_graph(G) -> {ok, DotFile} when
G :: #{ nodes => Nodes, edges => Edges },
Nodes :: [ template_node() ],
Edges :: [ template_edge() ],
DotFile :: binary().
dot_from_graph(G) ->
Nodes = lists:map(
fun(#{ module := Mod, id := Id, template := Tpl, filepath := FilePath } = Node) ->
Type = maps:get(type, Node, normal),
[
" ", Id,
" [",
"label=\"", Tpl, "\\n", Mod, "\"",
" color=", color(Mod),
case Mod of
<<"mod_", _/binary>> -> <<>>;
_ -> " fillcolor=powderblue style=filled "
end,
<<" href=\"#">>, escape(FilePath), <<"\" ">>,
case Type of
root -> <<" shape=hexagon">>;
_ -> <<>>
end,
"];\n"
]
end,
maps:get(nodes, G, [])),
Edges = lists:map(
fun(#{ from := From, to := To, module := Mod } = E) ->
[
" ", From, " -> ", To,
case maps:get(type, E, false) of
extends ->
[ " [style=dashed dir=back color=", color(Mod),"] " ];
overrules ->
[ " [style=dotted dir=\"back\" color=", color(Mod),"] " ];
_ ->
[ " [color=", color(Mod), "] " ]
end,
";\n"
]
end,
maps:get(edges, G, [])),
Dot = iolist_to_binary([
<<"digraph G {\n">>,
" rankdir=LR;\n",
" node [ fontsize=11 shape=box color=cadetblue ];\n",
"\n",
Nodes,
"\n",
lists:usort(Edges),
<<"}\n">>
]),
{ok, Dot}.
escape(Path) ->
binary:replace(Path, <<"\"">>, <<"\\\"">>, [ global ]).
-spec filename_to_node(NodeId, Path) -> template_node() when
NodeId :: binary(),
Path :: binary().
filename_to_node(NodeId, Path) ->
BuildDir = <<(build_dir())/binary, "/">>,
filename_to_node(NodeId, Path, BuildDir).
filename_to_node(NodeId, Path, BuildDir) ->
Path1 = binary:replace(Path, BuildDir, <<>>),
{Mod, Tpl} = case binary:split(Path1, <<"/priv/templates/">>) of
[M,T] -> {M, T};
[T] -> {<<>>, T}
end,
#{
id => NodeId,
module => app_to_module_name(Mod),
template => Tpl,
basename => basename(Tpl),
filepath => Path
}.
app_to_module_name(<<"/", M/binary>>) -> app_to_module_name(M);
app_to_module_name(<<"zotonic_mod_", M/binary>>) -> <<"mod_", M/binary>>;
app_to_module_name(M) -> M.
-spec graph(Context) -> {ok, Graph} when
Context :: z:context(),
Graph :: #{ nodes => Nodes, edges => Edges },
Nodes :: [ template_node() ],
Edges :: [ template_edge() ].
graph(Context) ->
% Build an index of all "top" level templates reachable by
% lookup, skip templates hidden by like-named templates in
% modules of higher priority.
All = z_module_indexer:all(template, Context),
Ts = collect_nodes(All, Context),
TsWithHidden = collect_hidden(Ts, Context),
TsWithHiddenList = maps:values(TsWithHidden),
TsWithHiddenN = lists:zip(TsWithHiddenList, lists:seq(1, length(TsWithHiddenList))),
BuildDir = <<(build_dir())/binary, "/">>,
NodeMap = lists:foldl(
fun({Template, N}, Acc) ->
#module_index{ key = Key, filepath = Path } = Template,
#module_index_key{ name = TplName } = Key,
NodeId = <<"n", (integer_to_binary(N))/binary>>,
Node0 = filename_to_node(NodeId, Path, BuildDir),
Node = Node0#{
index => Template
},
case maps:is_key(Path, Ts) of
true ->
% "Top" level templates use their short-hand template
% name for the index. This is used for finding includes
% and extends.
Acc#{
TplName => Node
};
false ->
% Templates that are hidden by other (higher prio) templates
% but are used for overrules or all-include are indexed by
% their full path.
Acc#{
Path => Node
}
end
end,
#{},
TsWithHiddenN),
Nodes = maps:values(NodeMap),
Edges = edges(Nodes, NodeMap, Context),
Extends = extend_edges(Nodes, NodeMap, Context),
G = #{
nodes => Nodes,
edges => Extends ++ Edges
},
{ok, G}.
collect_nodes(All, Context) ->
{Ns, _} = lists:foldl(
fun(Template, {Acc, Dedup}) ->
#module_index{ key = Key } = Template,
#module_index_key{ name = TplName } = Key,
case maps:get(TplName, Dedup, undefined) of
undefined ->
{ok, T} = z_module_indexer:find(template, TplName, Context),
#module_index{ filepath = Path } = T,
Dedup1 = Dedup#{
TplName => true
},
Acc1 = Acc#{
Path => T
},
{Acc1, Dedup1};
_ ->
{Acc, Dedup}
end
end,
{#{}, #{}},
All),
Ns.
%% @doc Collect overrules and 'all include' templates that are normally hidden
%% by the like-named template in the module with highest priority.
collect_hidden(TplMap, Context) ->
Tpls = maps:values(TplMap),
TplMap1 = collect_overrules(Tpls, TplMap, Context),
Tpls1 = maps:values(TplMap1),
collect_all_includes(Tpls1, TplMap1, Context).
collect_all_includes([], Map, _Context) ->
Map;
collect_all_includes([Template|Ts], Map, Context) ->
case z_template:includes(Template, #{}, Context) of
{ok, Includes} ->
{Extra, Map1} = lists:foldl(
fun
(#{
template := IncFile,
method := all % optional | all | normal
}, Acc) ->
All = z_module_indexer:find_all(template, IncFile, Context),
lists:foldl(
fun(Tpl, {ExtraAcc, MapAcc}) ->
#module_index{ key = Key, filepath = Path } = Tpl,
#module_index_key{ name = TplName } = Key,
case maps:get(TplName, Map, undefined) of
Tpl ->
{ExtraAcc, MapAcc};
_ ->
case maps:get(Path, Map, undefined) of
undefined ->
MapAcc1 = MapAcc#{
Path => Tpl
},
{[Tpl|ExtraAcc], MapAcc1};
_ ->
{ExtraAcc, MapAcc}
end
end
end,
Acc,
All);
(_, Acc) ->
Acc
end,
{[], Map},
Includes),
collect_all_includes(Extra ++ Ts, Map1, Context);
{error, _} ->
collect_all_includes(Ts, Map, Context)
end.
collect_overrules([], Map, _Context) ->
Map;
collect_overrules([Template|Ts], Map, Context) ->
case z_template:extends(Template, #{}, Context) of
{ok, overrules} ->
case find_overrules(Template, Context) of
{ok, Next} ->
#module_index{ key = Key, filepath = Path } = Next,
#module_index_key{ name = TplName } = Key,
case maps:get(TplName, Map, undefined) of
Next ->
collect_overrules(Ts, Map, Context);
_ ->
case maps:get(Path, Map, undefined) of
undefined ->
Map1 = Map#{
Path => Next
},
collect_overrules([Next|Ts], Map1, Context);
_ ->
collect_overrules(Ts, Map, Context)
end
end;
{error, _} ->
collect_overrules(Ts, Map, Context)
end;
_ ->
collect_overrules(Ts, Map, Context)
end.
find_overrules(#module_index{ key = Key, filepath = Filename }, Context) ->
#module_index_key{ name = TplName } = Key,
Templates = z_module_indexer:find_all(template, TplName, Context),
case find_next_template(Filename, Templates) of
{ok, _} = Ok ->
Ok;
{error, enoent} ->
{error, enoent}
end.
find_next_template(_Filename, []) ->
{error, enoent};
find_next_template(Filename, [#module_index{filepath=Filename},Next|_]) ->
{ok, Next};
find_next_template(Filename, [_|Rest]) ->
find_next_template(Filename, Rest).
extend_edges(Nodes, NodeMap, Context) ->
lists:filtermap(
fun(#{ index := Template, id := FromId, module := Mod }) ->
case z_template:extends(Template, #{}, Context) of
{ok, Extends} when is_binary(Extends) ->
case maps:get(Extends, NodeMap, undefined) of
#{
id := ToId
} ->
{true, #{
from => ToId,
to => FromId,
module => Mod,
type => extends
}};
undefined ->
false
end;
{ok, overrules} ->
case find_overrules(Template, Context) of
{ok, Next} ->
#module_index{ filepath = Path } = Next,
case maps:get(Path, NodeMap, undefined) of
undefined ->
false;
#{ id := ToId } ->
{true, #{
from => ToId,
to => FromId,
module => Mod,
type => overrules
}}
end;
{error, _} ->
false
end;
{ok, undefined} ->
false;
{error, _} ->
false
end
end,
Nodes).
edges(Nodes, NodeMap, Context) ->
lists:flatten(
lists:map(
fun(#{ index := Template, id := FromId, module := Mod }) ->
case z_template:includes(Template, #{}, Context) of
{ok, Includes} when is_list(Includes) ->
lists:filtermap(
fun
(#{
template := IncFile,
method := all, % optional | all | normal
is_catinclude := _
}) ->
All = z_module_indexer:find_all(template, IncFile, Context),
L = lists:filtermap(
fun(Tpl) ->
#module_index{ filepath = Path } = Tpl,
case maps:get(IncFile, NodeMap, undefined) of
#{ index := #module_index{ filepath = Path }, id := ToId } ->
{true, #{
from => FromId,
to => ToId,
module => Mod,
type => include
}};
_ ->
case maps:get(Path, NodeMap, undefined) of
#{ id := ToId } ->
{true, #{
from => FromId,
to => ToId,
module => Mod,
type => include
}};
_ ->
false
end
end
end,
All),
{true, L};
(#{
template := IncFile,
is_catinclude := true
}) ->
Included = find_all_catinclude(IncFile, Nodes),
Es = lists:map(
fun
(#{ id := ToId }) ->
#{
from => FromId,
to => ToId,
module => Mod,
type => include
}
end,
Included),
{true, Es};
(#{
template := IncFile,
is_catinclude := false
}) ->
case maps:get(IncFile, NodeMap, undefined) of
#{
id := ToId
} ->
{true, #{
from => FromId,
to => ToId,
module => Mod,
type => include
}};
undefined ->
false
end
end,
Includes);
{error, _} ->
[]
end
end,
Nodes)
).
find_all_catinclude(Template, Nodes) ->
lists:filter(
fun(#{ basename := B }) -> B =:= Template end,
Nodes).
% Determine the base name of a template, as used with catinclude.
basename(Tpl) ->
Parts = binary:split(Tpl, <<"/">>, [ global ]),
[Filename|RevDir] = lists:reverse(Parts),
Filename1 = case binary:split(Filename, <<".">>, [ global ]) of
[ F, <<"name">>, _, <<"tpl">> ] ->
<<F/binary, ".tpl">>;
[ F, _, <<"tpl">> ] ->
<<F/binary, ".tpl">>;
_ ->
Filename
end,
iolist_to_binary(lists:join($/, lists:reverse([Filename1 | RevDir]))).
build_dir() ->
unicode:characters_to_binary(z_path:build_lib_dir()).
color(Name) ->
Nr = erlang:phash2(Name, length(colors())),
lists:nth(Nr+1, colors()).
% https://graphviz.org/doc/info/colors.html#svg
% commented out colors too light for a white background
colors() ->
[
% <<"aliceblue">>,
% <<"antiquewhite">>,
<<"aqua">>,
<<"aquamarine">>,
% <<"azure">>,
<<"beige">>,
<<"bisque">>,
<<"black">>,
<<"blanchedalmond">>,
<<"blue">>,
<<"blueviolet">>,
<<"brown">>,
<<"burlywood">>,
<<"cadetblue">>,
<<"chartreuse">>,
<<"chocolate">>,
<<"coral">>,
<<"cornflowerblue">>,
% <<"cornsilk">>,
<<"crimson">>,
<<"cyan">>,
<<"darkblue">>,
<<"darkcyan">>,
<<"darkgoldenrod">>,
<<"darkgray">>,
<<"darkgreen">>,
<<"darkgrey">>,
<<"darkkhaki">>,
<<"darkmagenta">>,
<<"darkolivegreen">>,
<<"darkorange">>,
<<"darkorchid">>,
<<"darkred">>,
<<"darksalmon">>,
<<"darkseagreen">>,
<<"darkslateblue">>,
<<"darkslategray">>,
<<"darkslategrey">>,
<<"darkturquoise">>,
<<"darkviolet">>,
<<"deeppink">>,
<<"deepskyblue">>,
<<"dimgray">>,
<<"dimgrey">>,
<<"dodgerblue">>,
<<"firebrick">>,
% <<"floralwhite">>,
<<"forestgreen">>,
<<"fuchsia">>,
<<"gainsboro">>,
% <<"ghostwhite">>,
<<"gold">>,
<<"goldenrod">>,
<<"gray">>,
<<"grey">>,
<<"green">>,
% <<"greenyellow">>,
% <<"honeydew">>,
<<"hotpink">>,
<<"indianred">>,
<<"indigo">>,
% <<"ivory">>,
<<"khaki">>,
<<"lavender">>,
% <<"lavenderblush">>,
<<"lawngreen">>,
% <<"lemonchiffon">>,
<<"lightblue">>,
<<"lightcoral">>,
% <<"lightcyan">>,
<<"lightgoldenrodyellow">>,
<<"lightgray">>,
<<"lightgreen">>,
<<"lightgrey">>,
<<"lightpink">>,
<<"lightsalmon">>,
<<"lightseagreen">>,
<<"lightskyblue">>,
<<"lightslategray">>,
<<"lightslategrey">>,
<<"lightsteelblue">>,
% <<"lightyellow">>,
<<"lime">>,
<<"limegreen">>,
% <<"linen">>,
<<"magenta">>,
<<"maroon">>,
<<"mediumaquamarine">>,
<<"mediumblue">>,
<<"mediumorchid">>,
<<"mediumpurple">>,
<<"mediumseagreen">>,
<<"mediumslateblue">>,
<<"mediumspringgreen">>,
<<"mediumturquoise">>,
<<"mediumvioletred">>,
<<"midnightblue">>,
% <<"mintcream">>,
<<"mistyrose">>,
<<"moccasin">>,
<<"navajowhite">>,
<<"navy">>,
% <<"oldlace">>,
<<"olive">>,
<<"olivedrab">>,
<<"orange">>,
<<"orangered">>,
<<"orchid">>,
<<"palegoldenrod">>,
<<"palegreen">>,
<<"paleturquoise">>,
<<"palevioletred">>,
<<"papayawhip">>,
<<"peachpuff">>,
<<"peru">>,
<<"pink">>,
<<"plum">>,
<<"powderblue">>,
<<"purple">>,
<<"red">>,
<<"rosybrown">>,
<<"royalblue">>,
<<"saddlebrown">>,
<<"salmon">>,
<<"sandybrown">>,
<<"seagreen">>,
% <<"seashell">>,
<<"sienna">>,
<<"silver">>,
<<"skyblue">>,
<<"slateblue">>,
<<"slategray">>,
<<"slategrey">>,
% <<"snow">>,
<<"springgreen">>,
<<"steelblue">>,
<<"tan">>,
<<"teal">>,
<<"thistle">>,
<<"tomato">>,
<<"turquoise">>,
<<"violet">>,
<<"wheat">>
% <<"white">>,
% <<"whitesmoke">>,
% <<"yellow">>,
% <<"yellowgreen">>
].