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.
What is refused is a construct, never the characters of one. A template may print the text of a tag this subset forbids - a screen telling an author which tags it will not take - and that template renders those characters.
iex> {:ok, compiled} = Riddler.Template.compile(~S({{ "{% liquid %}" }} is refused))
iex> Riddler.Template.render(compiled, %{}, :strict)
{:ok, "{% liquid %} is refused", []}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"]A source that does not parse is refused, and as what depends on the reason
A template the parser cannot read is refused like any other template, and
which code comes back depends on the shape of the reason the parser gave.
One shape is singled out: the parser reporting that it met a tag it did not
expect. That refusal is reported as that tag, template.tag_not_allowed,
with the tag in field, because the author reached for a construct the
subset does not admit and naming the construct is the answer they can act
on. Every other reason is a parse failure, template.parse_error, with a
null field and the parser's own reason carried in the message - including
a reason that names a tag for some other purpose, such as a closer the
parser was still waiting for.
iex> {:error, [finding]} = Riddler.Template.compile("done{% endif %}")
iex> {finding.code, finding.field}
{"template.tag_not_allowed", "endif"}
iex> {:error, [finding]} = Riddler.Template.compile("{% if a %}x")
iex> {finding.code, finding.field, finding.message =~ "endif"}
{"template.parse_error", nil, true}
iex> {:error, [finding]} = Riddler.Template.compile("Nice to meet you, {{ responses.first_name")
iex> {finding.code, finding.field}
{"template.parse_error", nil}A tag the subset refuses reaches this code too when it is written in a way
the parser cannot read: the parser gives up before there is a node for the
allowlist walk to ask about, so the answer is the parse failure rather than
the name of the construct. {% render %}, {% cycle %}, {% tablerow %}
and {% increment %} each answer template.parse_error with a null
field, where the well-formed {% render 'x' %} answers
template.tag_not_allowed naming render; those four are examples of the
shape and not the whole of it. So a host shows the parser's reason on this
code rather than expecting a construct to name.
A parse failure is also the one refusal in this module that can arrive with
no position on it. It carries one when the parser named a place and nil
when the parser refused the source without naming one, which is what
{% render %} does, so a host that points at a span checks position for
nil on this code where it need not on the other two.
Both halves of the split are pinned by the conformance corpus, which is
where a second runtime meets it, stated by what the source holds rather
than by what this parser says of it: "A closing tag written where no block
of its tag is open is refused as that tag, not as a parse error", "An
unterminated output tag is refused as a parse error, and the finding names
no field", and "A render tag naming no template is refused as a parse
error, as a finding rather than a raise, and the finding names no field".
The corpus does not carry a finding's position, so that the placeless
refusal carries a nil one is pinned by this package's own tests rather
than by the corpus.
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.
A condition tests a value rather than reading one
Neither mode reports a variable that appears only in the condition of an
if, an elsif or an unless. A condition asks a question, and a path the
roots do not carry makes that question false, so the branch that holds
renders and nothing is missing. The rule is positional: the whole condition
is one position, whatever expression stands in it - a bare path, a
comparison, a chain joined by and or or - and the same path read in an
output tag, iterated by for, taken as a case subject or assigned is
reported as it would be anywhere else.
iex> {:ok, compiled} = Riddler.Template.compile("{% unless responses.newsletter %}Sign up?{% endunless %}")
iex> Riddler.Template.render(compiled, %{}, :strict)
{:ok, "Sign up?", []}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, and a variable appearing
only in the condition of an if, an elsif or an unless, 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"]}