The safe template subset: a deliberately small slice of Liquid.
A screen document carries authored prose that is not fixed text - a label that greets a visitor by the name they just gave, a summary line that reads back what they chose. Those strings are templates, and this module is what decides which templates a document may carry.
The subset is an allowlist
What this module accepts is enumerated here. Everything else is refused,
including constructs Liquid or the parser underneath may add later. The
admitted tags are assign, capture, case, comment, for, if, raw
and unless, together with the elsif, else and when branches that
belong to them. The admitted filters are the standard string, number, array
and date filters, plus default; the filters that know about markup -
escape, escape_once, newline_to_br and strip_html - are refused,
because this package does not emit markup and does not escape.
Refusal happens at compile, never at render
compile/1 takes template source and no context at all, so an editor can
tell an author that a template is wrong while the author is still looking at
it, and the answer it gets is the same answer the runtime would reach. A
refusal reports one finding per refused construct rather than the first, so
that one pass over the findings is enough to fix the template.
iex> {:error, findings} = Riddler.Template.compile("{% include 'footer' %}")
iex> Enum.map(findings, & &1.field)
["include"]Output is text, never markup
A template produces a string that means exactly the characters in it. A value containing markup renders as the literal characters of that markup: escaping is the act of a renderer that knows what it is rendering into, and this package does not know. A host rendering into HTML escapes what it is given here, exactly as it escapes any other untrusted string.
Two render modes
render/3 takes lenient or strict, and they differ only in what a missing
thing does. Lenient renders a missing variable as the empty string and
returns the list of what was missing; it is the runtime mode, because a
visitor should see a screen rather than an error page when an optional field
has not been filled. Strict returns the missing list as an error; it is the
mode for preview and for the conformance corpus, where an author and a case
both need to be told. On a template where nothing is missing the two modes
agree.
A variable guarded by default is not missing in either mode: default is
how an author says a field is optional, and lenient mode's empty string is
the fallback for what an author did not anticipate.
Assigns are string-keyed
The map handed to render/3 is the document's roots - responses and
context - and those are decoded JSON, so every key in it and in the maps
under it is a string.
iex> {:ok, compiled} = Riddler.Template.compile("Hi {{ responses.first_name }}!")
iex> Riddler.Template.render(compiled, %{"responses" => %{"first_name" => "Ada"}}, :strict)
{:ok, "Hi Ada!", []}
Summary
Functions
Compiles template source against the subset.
Renders a compiled template against assigns in lenient or strict mode.
Functions
@spec compile(String.t()) :: {:ok, Riddler.Template.Compiled.t()} | {:error, [Riddler.Finding.t()]}
Compiles template source against the subset.
Returns {:ok, compiled} when every construct in the source is inside the
allowlist, and {:error, findings} otherwise, with one finding per refused
construct in source order. No context is consulted and none is needed: a
template that compiles here compiles anywhere, and a template refused here
is refused before any visitor exists.
iex> {:error, [finding]} = Riddler.Template.compile("{{ name | strip_html }}")
iex> {finding.code, finding.field}
{"template.filter_not_allowed", "strip_html"}
@spec render(Riddler.Template.Compiled.t(), map(), :lenient | :strict) :: {:ok, String.t(), [String.t()]} | {:error, [String.t()]}
Renders a compiled template against assigns in lenient or strict mode.
Lenient returns {:ok, text, missing}, where a missing variable rendered as
the empty string and its path is in the list. Strict returns
{:ok, text, []} when nothing was missing and {:error, missing} when
something was. A variable guarded by default is missing in neither mode.
iex> {:ok, compiled} = Riddler.Template.compile("Hi {{ responses.first_name }}!")
iex> Riddler.Template.render(compiled, %{}, :lenient)
{:ok, "Hi !", ["responses.first_name"]}
iex> Riddler.Template.render(compiled, %{}, :strict)
{:error, ["responses.first_name"]}