phoenix_kit_templates

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

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")]
)
#=> %{subject: "…", text: "…", html: nil}

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 passes the default in already localized. That split is the whole design: translation belongs to the sending package's Gettext backend, and reaching for one here would make this package depend on it. A shipped default expressed as one file per locale would fork the same sentence seven ways and drift; expressed as a Gettext call it rides the extraction and translation pipeline that package already runs.

Files are how a host overrides — a host writes one or two languages, not seven — and how chrome-bearing HTML gets authored.

Resolution runs per part, independently:

1. host override · recipient locale text.en-GB.txt
2. host override · base language text.en.txt
3. host override · locale-less text.txt
4. the default the caller passed in

Independence matters: a host that overrides only html keeps the package's translated subject and text rather than having to restate them.

Override layout

The template's name is a directory, not a filename. 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

Parts are named for what they are rather than for email, because every channel reads the same three:

partfileread by
subjectsubject[.locale].txtemail subject line, push title
texttext[.locale].txtemail body, push body, Telegram, SMS, in-app inbox
htmlhtml[.locale].htmlemail only — optional

Given that tree, a German recipient resolves to subject.de.txt + text.de.txt; an Italian one falls through to subject.txt + text.txt. A host that wrote only text.txt still gets the package's translated subject in every language — parts are looked up independently.

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.

html is genuinely optional, not nominally so: a text-only template is the normal case. Core's own auth emails ship without one — short transactional messages where plain text wins on the merits (no image blocking, no dark-mode breakage, no client-specific CSS, better deliverability).

Overrides live in the host application, which compiles separately from this package — so they are read at runtime and cached in :persistent_term, the absence of a file included.

The name is the slug

There is no separate slug, and no display_name. name is validated as [a-z0-9][a-z0-9_\-]* — slug-shaped by construction, because it is a directory name on disk and has to be safe to join onto a path.

The database schema this replaces carried both, for a reason that no longer exists: name identified the template while slug addressed the admin editor's routes (…/templates/:slug), and display_name labelled its list view. With no editor and no routes, a second identifier addresses nothing. A human-readable label, if one is ever wanted, is a Gettext call in the sending package rather than a stored column — the same place its subject and body already live.

Placeholders

{{variable}}, with optional inner whitespace, matching the syntax the database-backed email templates used so exported content carries over unchanged. Atom and string keys are both accepted.

An unbound placeholder is left verbatim, not blanked, and never raises. Both 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 — and a message with one flawed line still beats a message that never arrives. Callers that want it to be an error ask missing_variables/4 up front.

No runtime dependencies

Deliberate: phoenix_kit depends on this package, so anything pulled in here lands upstream of the entire tree.

License

MIT