PhoenixKit.Templates (phoenix_kit_templates v0.1.1)

View Source

Renders a named message template for a recipient.

One renderer for every channel — email, push, Telegram, SMS, the in-app inbox — so localized, customizable message content is built the same way everywhere instead of once per channel.

The two layers

A template has a default, shipped by the package that sends the message, and an optional override, shipped by the host application.

The caller supplies the default already localized. That split is what keeps this package a leaf: translation belongs to the sending package's Gettext backend, and reaching for one here would mean depending on it.

PhoenixKit.Templates.render(
  "new_login_alert",
  %{subject: gettext("New login to your account"), text: gettext("Hi %{email}, …")},
  %{"ip_address" => ip},
  locale: "uk",
  paths: [Application.app_dir(:my_app, "priv/phoenix_kit_templates")]
)

On disk

A template's name is a DIRECTORY, never a filename. The files inside it are named for the part they supply, optionally carrying a locale:

priv/phoenix_kit_templates/     <- a root, passed as :paths
 new_login_alert/            <- the template NAME (a directory)
     subject.txt             <- <part>.<ext>
     subject.de.txt          <- <part>.<locale>.<ext>
     text.txt
     text.de.txt
     html.html
partfileused by
subjectsubject[.locale].txtemail subject, push title
texttext[.locale].txtevery channel
htmlhtml[.locale].htmlemail only, optional

A directory rather than flat files because one template is up to three parts times however many locales a host translates — flat, they would interleave with every other template's files and you would be reading filename prefixes to tell them apart. Grouped, a template is one folder to copy, diff or delete.

This is also why name is the only identifier and is validated as [a-z0-9][a-z0-9_-]*: it is a path segment, so it must be filesystem-safe. It is slug-shaped by necessity, which is what makes a separate slug field a second spelling of a constraint the path already enforces.

Resolution

Per part, independently, stopping at the first hit. For part subject and a recipient locale of "de-AT":

1. subject.de-AT.txt      host override · exact dialect
2. subject.de.txt         host override · base language
3. subject.txt            host override · locale-less
4. defaults[:subject]     the caller's Gettext default

Roots are tried in order, so an earlier root shadows a later one.

Independence matters. Given the tree above and a German recipient, a host that wrote only text.txt still gets the package's translated German subject; the override applies to the body alone. And subject.de.txt wins for a German reader while an Italian one falls through to subject.txt.

Why the default is not a file

A shipped default expressed as one file per locale forks the same sentence seven ways and drifts. Expressed as a Gettext call it rides the extraction and translation pipeline the sending package already runs, and ships translated. Files are how a host overrides — a host writes one or two languages, not seven — and how chrome-bearing HTML gets authored. See the design doc.

Parts

subject, text and html, named for what they are rather than for email: push uses subject-as-title plus text, Telegram and SMS use text alone, the in-app inbox uses text. html is genuinely optional — core's own auth emails ship text-only, so a template with no html is the normal case.

Summary

Types

Package-shipped content, already localized by the caller.

Rendered content, ready for a channel to deliver.

Functions

Placeholder names that render/4 would leave unbound, keyed by part.

Renders name into %{subject:, text:, html:}.

Types

defaults()

@type defaults() :: %{
  optional(PhoenixKit.Templates.Overrides.part()) => String.t() | nil
}

Package-shipped content, already localized by the caller.

rendered()

@type rendered() :: %{
  subject: String.t() | nil,
  text: String.t() | nil,
  html: String.t() | nil
}

Rendered content, ready for a channel to deliver.

Functions

missing_variables(name, defaults, variables \\ %{}, opts \\ [])

Placeholder names that render/4 would leave unbound, keyed by part.

Parts that would render cleanly are omitted, so an empty map means the render is fully bound. Intended for a test or a preview screen — render/4 itself never fails over a bad placeholder, because a message with one flawed line is still better than a message that never arrives.

render(name, defaults, variables \\ %{}, opts \\ [])

Renders name into %{subject:, text:, html:}.

Options

  • :locale — the recipient's locale, dialect included ("en-GB"). nil selects only locale-less overrides.
  • :paths — host override roots, tried in order. Defaults to [], which means the caller's defaults are used verbatim.

Unbound {{placeholders}} survive into the output rather than blanking or raising; see PhoenixKit.Templates.Substitution.