Changelog
View Source0.2.4 - 2026-10-07
Added
- Editor: a text version in the preview, and converters. A
previewmay return{subject, html, text}; the preview then has HTML and Text tabs ({subject, html}works as before). An optionalconvertattribute,%{to_text: callback, markdown_to_html: callback}, adds Fill text from content (Markdown if not blank, else HTML, into Text) and Markdown → HTML (into HTML). They fill the form, not the files: the user's other unsaved fields are kept, and Save writes the converted part like an edited one (a part a save refuses stays in the form). Converters are called with the form's text as typed, placeholders included, and should return the placeholders as they are, in link targets too (see the Markdown note under 0.2.3's Documentation); they fail like the other callbacks, leaving the form as it was. The buttons submit the form with anaction(LiveView 1.0 or later); Save is the first, and a submit that names none saves. No buttons read-only or withoutconvert;convertmay also be a keyword list. A field the user has focused when a conversion returns keeps what is in it (the browser's rule for a focused input).
Changed
- Editor: clicking the open template or language tab again keeps the form. It used to reload the form from the files, dropping unsaved input; now it only clears the notice and a pending delete confirmation.
0.2.3 - 2026-10-07
Added
- A write API on one root:
Overrides.write/5,delete/4,delete_template/2,copy_template/3,list/1, plusvalid_name?/1andmax_bytes/0. Same layout, name and locale rules as reading, but a malformed locale is refused instead of falling back; the path stays inside the root (symlinks included); writes are atomic (synced, then renamed; a replaced file keeps its permissions) and capped at 256 KiB of UTF-8; the cache is reset for the root after every change, a faileddelete_template/2included.delete/4removes the template directory along with its last file, since git does not track an empty one. Errors are{:error, reason}.write/5returns the paths it created — the template directory too when the write is its first part file — so a host can fix their ownership.copy_template/3copies every regular file of a template directory, a host's own files beside the parts included, through a hidden directory renamed into place, and returns the new directory and its files. - A
labelpart (label[.locale].txt) that the write API andlist/1know and rendering never reads — a caption for an editor's list. PhoenixKit.Templates.Editor, aPhoenix.LiveComponentfor editing those files: list (shared_-prefixed parts grouped apart), per-language editing oflabel/subject/text/markdown/html, create empty or as a copy, delete with confirmation, a host preview in a script-less sandboxed iframe, read-only mode, anafter_writecallback (the paths a save or copy created) and anafter_changecallback (the template's name after any change on disk, deletions included).name_prefixesis checked on every write, an unsaved draft included; apreview,after_writeorafter_changethat raises, throws or exits is shown as an error, not a crash, and logged with its stacktrace. A preview must return strings ornil(a{:safe, _}is escaped, anything else is an error); it is not called for a template with no files, and is called again when the host re-renders with a new callback, new variables or changed files. A file that is not UTF-8 is shown read-only and never written over. A save that is partly refused names the parts saved and the parts refused; a part the user left alone is never written back over another session's change, an edited part wins with no check for one. Compiled only whenphoenix_live_viewis present; it is an optional dependency, and nothing depends on PhoenixKit.
Fixed
- The editor no longer throws away what was typed into a part a save refused (one too large, a write error): the text stays in the form, still an edit to fix and save again. Before, the form was redrawn from disk, so the part came back as it was on disk.
- The editor cleans
locales(anything but a list of non-empty strings, and duplicates) andsample_variables(anything but a map) the way it already cleanedname_prefixes, instead of crashing the page. - Subject normalization no longer takes quadratic time on long whitespace runs
without a line break. Unicode whitespace around a
\r/\nis now collapsed along with the break, matching the documented single-space behavior.
Documentation
- Corrected the Markdown substitution guidance: rendering a link target such as
{{url}}can percent-encode its braces, so callers substituting after Markdown rendering must preserve and restore placeholders before HTML substitution.
0.2.2 - 2026-10-01
Added
markdownandlayoutparts.render/4now returns%{subject:, text:, html:, markdown:, layout:}.markdown(markdown[.locale].md) is returned exactly as read, with no placeholder substitution, so the caller can render Markdown before substituting.layout(layout.txt) names a layout group; it is locale-less, trimmed and unsubstituted.missing_variables/4reports amarkdownpart's unbound placeholders and never reportslayout. (#3)PhoenixKit.Templates.sources/3reports whether each part came from a host file ({:file, path}) or from the caller's defaults (:default), on the same resolution asrender/4.PhoenixKit.Templates.Overrides.locate/4isread/4that also returns the file's path.read/4is built on it.
Changed
subjectis a single line. It is returned trimmed, without a leading byte-order mark, and any line break inside it (with the whitespace around it) becomes one space — for files, defaults and substituted values alike. The final newline an editor adds tosubject.txtis no longer part of the subject.text,htmlandmarkdownkeep their line breaks.- The
Overridescache key is tagged, so an entry cached by 0.2.1 is never read as a{path, content}pair after a live reload.
0.2.1 - 2026-10-01
Added
- A template name may start with one underscore (
_layout). Such names are reserved for shared parts that the caller assembles around a message; this package only finds the files, it wraps nothing. Names are now matched against\A_?[a-z0-9][a-z0-9_\-]*\z, so__x,_,_-xand anything containing.or/still resolve to no override and mint no cache entry. Ordinary names should not start with an underscore, but that is a convention the package does not enforce. (#2)
Changed
- The README and moduledocs no longer say core's auth emails are text-only. A
template with no
htmlis valid, and what a caller does without one is the caller's decision.
0.2.0 - 2026-09-23
Breaking
htmlnow HTML-escapes a bound{{variable}}value (&<>"', the setPhoenix.HTMLescapes). A value carrying markup — a user or company name — no longer becomes live HTML in an email.subjectandtextare plain text and are still never escaped.Upgrading: a host override whose
htmlpart relies on a variable carrying markup on purpose (billing'sline_items_html, a wrappedcontent) must switch that placeholder from{{variable}}to{{{variable}}}. A value that was being pre-escaped by the caller to compensate is now escaped twice — stop escaping it, or opt it out with triple braces.{{{variable}}}is now parsed in every part. In 0.1.x{{{x}}}rendered as{V}(literal outer braces around a substituted{{x}}); it now rendersV. This applies tosubjectandtexttoo, independent of escaping. Database templates inphoenix_kit_emails/phoenix_kit_newslettersstill use their own{{var}}-only substitution, so a database row must not be written with triple braces.
Added
{{{variable}}}(triple braces) substitutes raw in every part — the opt-out for a variable that already holds rendered HTML. (#1)Substitution.substitute/3with an:escapeoption. Unknown options and a non-boolean:escaperaiseArgumentError, so a typo likeescaped: truecannot silently render unescaped.Substitution.variables/1,missing/2andTemplates.missing_variables/4report triple-brace placeholders like double-brace ones.
Changed
- HTML escaping is a single pass over the value.
0.1.2 - 2026-09-16
Fixed
- Invalid input no longer creates cache entries.
Overrides.read/4checks the name, part and locale before it touches:persistent_term. A malformed locale now shares thenillocale's cache entry, and an invalid name or unknown part returnsnilwithout caching anything. Before, each of these added a permanent key, and every new key copies the whole table. Overrides.read/4now raises on a bare string root instead of quietly finding no overrides. The old behaviour also cached a key that made a laterreset_cache/1crash.render/4treatspaths: nilas[].- The hexdocs source links now point at the
v<version>tag instead of a ref that doesn't exist. - The README and moduledoc example called
gettext("Hi %{email}, …")without bindings, which raises. It now uses a{{user_email}}placeholder.
Changed
- Locales can have up to three subtags and lose one at a time on fallback:
zh-Hant-TW→zh-Hant→zh→ locale-less. Before, any locale with more than one subtag skipped straight to the locale-less file. render/4andmissing_variables/4now share one resolution function, so the check always sees the same content the render sends.
0.1.1 - 2026-09-06
Documentation
- Made the on-disk layout unmissable. A template's
nameis a directory, not a filename — the one thing the 0.1.0 docs never actually stated, and the first thing anyone writing an override needs to know. Every doc now leads with it and carries a worked tree:PhoenixKit.Templatesfor the conceptual model and the full resolution order,PhoenixKit.Templates.Overridesfor the lookup rules, and the README for practical use. - Recorded why there is no
slugand nodisplay_name:nameis validated as[a-z0-9][a-z0-9_\-]*precisely because it is a path segment, so it is slug-shaped by necessity and a second identifier would address nothing. - Folded the duplicated part/extension listing into one table, keyed by which channel reads each part.
No code changes; 0.1.0 and 0.1.1 are functionally identical.
0.1.0 - 2026-09-06
Initial release.
PhoenixKit.Templates.render/4resolves a named template per part — host override for the recipient's locale, then base language, then locale-less, then the caller's default — and substitutes{{variables}}. Parts resolve independently, so a host overriding onlyhtmlkeeps the package's translatedsubjectandtext.PhoenixKit.Templates.missing_variables/4reports unbound placeholders per part, for a test or a preview screen.render/4itself never raises over one: a message with a flawed line still beats a message that never arrives.PhoenixKit.Templates.Overridesreads host override files at runtime and caches them in:persistent_term, the absence of a file included.nameandlocaleare validated before being joined onto a root.- No runtime dependencies, deliberately:
phoenix_kitdepends on this package, so anything pulled in here lands upstream of the entire tree.