Riddler.Template (Riddler v0.3.1)

Copy Markdown View Source

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

compile(source)

@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"}

render(compiled, assigns, mode)

@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"]}