PhoenixKit. Templates. Overrides
(phoenix_kit_templates v0.2.4)
View Source
Locates a host application's override file for one part of one template.
A host customizes a message by dropping a file into its own repo, where it is
version-controlled and reviewable, instead of editing a database row through
an admin UI. Rendering only ever reads an override; write/5, delete/4,
delete_template/2, copy_template/3 and list/1 exist for a host that
edits its own files from a screen of its own — see "Writing" below.
Layout
name is a DIRECTORY, never a filename. Files inside it are named for the
part they supply, optionally carrying a locale:
<root>/<name>/<part>.<locale>.<ext> text.en-GB.txt
<root>/<name>/<part>.<ext> text.txtWorked:
priv/phoenix_kit_templates/ <- <root>
└── new_login_alert/ <- <name>
├── subject.txt
├── subject.de.txt
└── text.txtsubject, text and layout are .txt; html is .html; markdown is
.md. A root is typically Application.app_dir(:my_app, "priv/phoenix_kit_templates"), but this module
takes roots as an argument and reads no configuration of its own — it must
not know which application is using it.
layout is the one part with no locale: it names a layout group, which is
chosen per message rather than per language, so only layout.txt is ever
read and layout.<locale>.txt is ignored.
Lookup runs most- to least-specific and stops at the first file that exists:
text.en-GB.txt → text.en.txt → text.txtso a host that only cares about one language writes text.txt and is done,
while one that translates its overrides gets dialect precision. Roots are
tried in order, so an earlier root shadows a later one.
Each part is looked up separately: in the tree above, a German reader gets
subject.de.txt and text.txt, and an Italian reader gets subject.txt and
text.txt. A part with no file at all resolves to nil, and the caller falls
back to its own default.
locate/4 is read/4 that also returns the path of the file it read —
{path, content} — for callers that need to say where content came from.
read/4 is built on it, so the two always agree.
Reserved names
A name may start with one underscore (_layout). Such names are
reserved for shared parts that the caller assembles around a message — the
first is core's email layout, _layout. This package knows nothing about
layouts and wraps nothing: it finds _layout/html.html (and
html.<locale>.html) exactly as it finds any other name. The underscore is
meant to keep such a directory from colliding with a template name: by
convention ordinary names should not start with one, but this module does not
enforce that.
Runtime, not compile time
Overrides live in the host application, which is compiled separately from
this package — there is no point in this package's compilation at which they
could be read. So they are read at runtime and cached in :persistent_term,
including the absence of a file, since a missing override is the common case
and would otherwise cost a File.stat on every send. Files change through a
deploy, which starts a fresh VM, or through the write functions below, which
reset the cache for their own root; reset_cache/0 covers the rest — tests,
dev reloads, files edited by hand.
Path safety
name and locale are matched against strict patterns before they are ever
joined onto a root. A name is [a-z0-9][a-z0-9_-]* with an optional single
leading underscore; dots and slashes never match. They are literals at every
current call site, but this module turns a name into a filesystem read, and
that is not a boundary to leave to the caller's good behaviour —
../../../etc/passwd resolves to no override rather than to a file.
Writing
write/5, delete/4, delete_template/2, copy_template/3 and list/1
work on one root — the host's own directory, never a package's — and on
the same layout. They are plain File calls for a host-side editor;
rendering does not use them.
- Same validation as reading, but refusing instead of falling back. The
name must match the pattern above; a locale must be
nilor a well-formed tag (reading treats a junk locale asnil, which for a write would silently target the locale-less file);layouttakes no locale. - One more part,
label(label[.locale].txt): a human-readable caption for an editor's list. Rendering never reads it andparts/0does not include it. - The path stays inside the root, symlinks included
(
Path.safe_relative/2), and the root must already exist. - Atomic: the content goes to a temporary file in the same directory, is synced, and is renamed over the target, so a concurrent read sees the old file or the new one, never half of one. A replaced file keeps its permission bits. The directory is not synced after the rename: a crash right after a write may lose the rename and keep the old file, but never leaves the new name on an empty file.
- Two narrow races are left to the caller, both surfacing as an error,
neither losing data:
delete/4removing a directory its last file left empty while a concurrentwrite/5is creating a file in it (that write fails with{:error, :enoent}), andcopy_template/3checking that the target is absent before its final rename (an empty directory created at the target in between is replaced). - At most
max_bytes/0(256 KiB) of UTF-8 per file. - The cache is reset for the root (
reset_cache([root])) after every change, so the next render sees it. Pass the same root string the renderer gets in:paths— the cache is keyed on it. A first lookup that was already reading the old file when the change landed can still cache what it read, after the reset; that window is one file read long, and the next change orreset_cache/1for the root clears it.
Every refusal is an {:error, reason}; nothing raises on bad input.
write/5 returns the paths it created — the template directory first, when
the write is its first part file, then the file — and copy_template/3 the
new directory and its files, so a host can, for example, fix their ownership.
Summary
Types
Why a write, delete or listing refused or failed.
One part file found by list/1.
Which part of a template to look for.
A part write/5 accepts: every rendered part, plus label.
Functions
Copies the template directory <root>/<from> to a new <root>/<to>, and
resets the cache for root.
Deletes one part file — exactly the one write/5 would write for the same
arguments, with no locale fallback — and resets the cache for root.
Deletes the template directory <root>/<name> with everything in it, and
resets the cache for root.
The templates under root, sorted by name: [%{name:, files: [file_entry]}].
Like read/4, but also says which file the content came from:
{path, content}, or nil when there is no override.
The largest file, in bytes, that write/5 accepts.
The parts an override file can supply.
The contents of the best-matching override file, or nil when there is none.
Drops cached override lookups.
Writes content as <root>/<name>/<part>[.<locale>].<ext>, replacing any
file already there, and resets the cache for root.
Types
@type error() :: :invalid_root | :invalid_name | :invalid_part | :invalid_locale | :invalid_content | :too_large | :unsafe_path | File.posix()
Why a write, delete or listing refused or failed.
@type file_entry() :: %{ part: writable_part(), locale: String.t() | nil, path: Path.t(), mtime: DateTime.t() }
One part file found by list/1.
@type part() :: :subject | :text | :html | :markdown | :layout
Which part of a template to look for.
@type writable_part() :: part() | :label
A part write/5 accepts: every rendered part, plus label.
Functions
Copies the template directory <root>/<from> to a new <root>/<to>, and
resets the cache for root.
Every regular file is copied, not only the part files list/1 shows: a host
may keep files of its own beside the parts (a marker saying who a message is
for, say), and a copy without them would be a different template. Hidden
files (an interrupted write's temporary file), subdirectories and anything
leading out of the root are skipped. A file over max_bytes/0 refuses the
whole copy with {:error, :too_large}; an existing <to> is
{:error, :eexist}. The files are copied into a hidden directory that is
then renamed to <to>, so a failed copy leaves nothing behind and a reader
never sees half of one.
Returns {:ok, paths} — the new directory, then its files — or
{:error, reason}.
@spec delete(Path.t(), String.t(), writable_part(), String.t() | nil) :: :ok | {:error, error()}
Deletes one part file — exactly the one write/5 would write for the same
arguments, with no locale fallback — and resets the cache for root.
When that was the last file in the template directory, the directory goes too: git does not track an empty directory, so keeping it would leave a template that exists here and not after a checkout. A directory still holding anything else, a host's own file included, stays.
A missing file is {:error, :enoent}.
Deletes the template directory <root>/<name> with everything in it, and
resets the cache for root.
A missing directory is {:error, :enoent}.
@spec list(Path.t()) :: [%{name: String.t(), files: [file_entry()]}]
The templates under root, sorted by name: [%{name:, files: [file_entry]}].
Lists every directory whose name passes the name pattern — an empty one
included — and, inside it, every file that write/5 could have written
(label included), sorted by part and locale. Anything else (a temporary
file, layout.<locale>.txt, a symlink out of the root, a stray file) is
skipped. A root that does not exist, or is not a string, lists nothing.
Like read/4, but also says which file the content came from:
{path, content}, or nil when there is no override.
Validation, candidate order (locale → base language → locale-less, roots in
order) and the cache are exactly read/4's — read/4 is implemented on top
of this function, so the two can never disagree about which file wins. The
path is the one that was read (a root joined with the name and file), as a
preview screen would show it. An empty file is still found: whether empty
means absent is the caller's decision.
@spec max_bytes() :: pos_integer()
The largest file, in bytes, that write/5 accepts.
@spec parts() :: [part()]
The parts an override file can supply.
The contents of the best-matching override file, or nil when there is none.
locale may be nil, which skips straight to the locale-less candidate, as
does a locale that is not a well-formed tag. roots must be a list — a bare
string is a caller bug, and silently finding no overrides in it would hide
that.
@spec reset_cache([Path.t()] | :all) :: :ok
Drops cached override lookups.
write/5 and the other write functions call this for their root. Otherwise
only tests, dev reloads and files changed by hand need it: a deploy starts a
fresh VM.
Pass a list of roots to clear only the entries that consulted them. That
scoping is what lets an async test clear its own tmp_dir without erasing a
concurrently-running one's cache — and it is the honest shape anyway, since a
dev reload usually means one application's files changed, not all of them.
Whether name is a valid template name — the pattern read/4 and write/5
both apply — for a caller that wants to check a name before writing to it.
@spec write(Path.t(), String.t(), writable_part(), String.t() | nil, String.t()) :: {:ok, [Path.t()]} | {:error, error()}
Writes content as <root>/<name>/<part>[.<locale>].<ext>, replacing any
file already there, and resets the cache for root.
part is any rendered part or :label; locale is nil for the
locale-less file. Returns {:ok, paths} — the template directory when this
is its first part file (a directory this call created, or one that held no
part file yet), then the file — or {:error, reason}; see "Writing" in the
module docs for what is refused.