PhoenixKit. Templates. Substitution
(phoenix_kit_templates v0.2.0)
View Source
{{variable}} substitution, with an {{{variable}}} opt-out from escaping.
The double-brace syntax matches what the database-backed email templates
used, so content exported from those rows carries over unchanged — as long
as it contains no triple brace. {{{variable}}} is new in 0.2.0 and is not
understood by those templates' own substitution: phoenix_kit_emails and
phoenix_kit_newsletters each run their own, older {{var}}-only regex
against database rows, which does not have a triple-brace rule and treats
the outer pair as literal text around a substituted inner pair instead (see
the boundary-case table below for exactly what that renders as). A database
row must therefore not be written with triple braces — the opt-out is only
meaningful once content is read by this package at 0.2.0 or later, for
example a host's file override, or content exported into one.
Surrounding whitespace is allowed: {{user_email}} and {{ user_email }}
are the same placeholder.
Escaping is per call, not per syntax
substitute/3 takes an :escape option (default false). When it is
true — which PhoenixKit.Templates.render/4 passes only for the html
part — a {{variable}} value is HTML-escaped (& < > " '), the same
set Phoenix.HTML escapes. This package has no runtime dependencies by
design (see the README), so escaping is implemented here rather than by
depending on phoenix_html. The value is always treated as data, never as
markup to preserve: a value that already contains & is escaped again,
because a renderer cannot tell "already-escaped markup" from a customer name
that happens to contain the literal text &.
{{{variable}}} (triple braces) is the escaping opt-out, for a variable that
already holds rendered HTML — billing's line_items_html is the motivating
case. It is always substituted raw, regardless of the :escape option,
in every part. In subject and text — which are never escaped, being
plain text — {{{variable}}} and {{variable}} are therefore identical:
the triple-brace spelling exists so the same template content is valid
pasted into any of the three parts, not because those parts have their own
escaping to opt out of.
substitute/2 calls substitute/3 with escape: false, i.e. plain
semantics: neither syntax escapes. That is also what subject and text
effectively get from render/4.
Unbound placeholders are left alone, verbatim, in either syntax
A placeholder with no matching variable is emitted exactly as written —
including its own spelling of inner whitespace — not blanked. Both
alternatives are wrong, but a visible {{user_emial}} announces the typo in
a preview or a test, while a silent empty string reads as finished copy and
ships. The renderer never raises over one — a message with a flawed
placeholder must still send — so callers that want it to be an error ask
missing/2 up front.
Parsing: one left-to-right pass, not "try one form, then the other"
Both forms are matched by a single regular expression, scanned once, left
to right. Correctness does not come from which alternative — triple-brace
or double-brace — is written first in the pattern: at any position right
after two {, the next character deterministically settles which one, if
either, can go on to match there. A third { can only continue a
triple-brace attempt; whitespace or a name's first character can only
continue a double-brace one. The two are mutually exclusive at every
position, so swapping their order in the pattern changes nothing — checked
directly against every case in the table below, not just argued.
What does matter is running one combined pass instead of two separate
ones. A first pass that only recognizes {{var}}, run before a second pass
for {{{raw}}}, would match the inner pair of a triple-brace placeholder
and leave a stray {value} behind — the failure the plan review flagged in
advance. A single left-to-right scan never gets the chance to make that
mistake: for each position it asks whether either full form completes right
there, instead of finding {{...}} first and only then looking outward for
a third brace.
Braces that do not complete a full double- or triple-brace placeholder are
left as literal text — this is what keeps single {/} in legacy content,
and CSS like body { margin: 0 } or @media (…) { … } inside an html
part, untouched. A placeholder never spans a literal brace that is not part
of it.
Boundary cases, fixed by test (substitution_test.exs) and worth stating
here because the "obvious" reading of a couple of them is wrong:
| input | with %{x: "V"} | why |
|---|---|---|
{{{x}}} | V, raw | plain triple-brace |
{{{ x }}} | V, raw | inner whitespace ignored, as with double braces |
{{ x }} | V (escaped if escape: true) | plain double-brace |
{{{{x}}}} | {V} | one literal { before it and one literal } after it; the middle seven characters, {{{x}}}, are the triple-brace placeholder |
{{{x}} | {V} (escaped if escape: true) | only two closing braces exist, so the triple form has nothing to complete there; the double form does, starting one character in, leaving the first { as literal |
{{x}}} | V} (escaped if escape: true) | mirror of the row above: the double form completes as {{x}}; the extra trailing } is literal |
{ {{x}} } | { V } | single outer braces are never part of any placeholder — a placeholder always starts with two consecutive { |
An unbound x in any of the rows above reproduces the input byte-for-byte:
the "why" column still applies to which span is a placeholder, but a
placeholder with no binding is emitted as its own matched text rather than
substituted.
Summary
Types
Variables to interpolate. Keys may be atoms or strings.
Functions
Placeholder names in content that variables does not bind.
Replaces every bound placeholder in content with plain semantics: neither
{{name}} nor {{{name}}} is escaped. Equivalent to substitute/3 with
escape: false.
Replaces every bound placeholder in content.
The placeholder names appearing in content, in order, without duplicates.
Types
Functions
Placeholder names in content that variables does not bind.
Recognizes both {{name}} and {{{name}}}, like variables/1.
Empty means every placeholder will be filled.
iex> PhoenixKit.Templates.Substitution.missing("{{a}} {{{b}}}", %{a: 1})
["b"]
Replaces every bound placeholder in content with plain semantics: neither
{{name}} nor {{{name}}} is escaped. Equivalent to substitute/3 with
escape: false.
iex> PhoenixKit.Templates.Substitution.substitute("Hi {{name}}", %{name: "Ada"})
"Hi Ada"
iex> PhoenixKit.Templates.Substitution.substitute("Hi {{name}}", %{})
"Hi {{name}}"
Replaces every bound placeholder in content.
Options
:escape— whentrue, a bound{{name}}value is HTML-escaped (&<>"', matchingPhoenix.HTML). Defaults tofalse.{{{name}}}is never escaped, regardless of this option — it is the opt-out for a variable that already holds rendered HTML.
Any other key, or a non-boolean :escape, raises ArgumentError — a caller
building this keyword list by hand gets a clear failure instead of a typo
(escaped: true) silently behaving like escape: false.
iex> PhoenixKit.Templates.Substitution.substitute(
...> "<p>{{name}}</p>",
...> %{name: "<b>Ada</b>"},
...> escape: true
...> )
"<p><b>Ada</b></p>"
iex> PhoenixKit.Templates.Substitution.substitute(
...> "<p>{{{name}}}</p>",
...> %{name: "<b>Ada</b>"},
...> escape: true
...> )
"<p><b>Ada</b></p>"
The placeholder names appearing in content, in order, without duplicates.
Recognizes both {{name}} and {{{name}}} — the name from either syntax is
"name".
iex> PhoenixKit.Templates.Substitution.variables("Hi {{name}}, {{{name}}} again")
["name"]