The words a runtime template asks for with t/1, and where they come from.
MishkaCmsCore.Runtime.Translation is the table and MishkaCmsCore.Runtime.States.TranslationsState
the ETS copy of it; this is everything around them — the render-time lookup, which keys a
template asks for, and registering them when it is saved. It stands to those two as
MishkaCmsCore.Settings stands to MishkaCmsCore.Runtime.Setting and SettingsState.
At render
<h2>{t("Categories")}</h2>
<p>{t("Read %{count} posts", count: @count)}</p>translate/2 answers from ETS alone, in this order, and never raises:
- this site's own row for the language being answered in
- the global row (
site_idnil) for that language - the key itself
The language is Gettext's locale for this process and the site is put_site/1's — both set in the
same two places, MishkaCmsCore.Runtime.Plugs.LanguagePlug and MishkaCmsWeb.RuntimeLive, so a
template never passes either. What comes back is a plain string; HEEx escapes it like any other,
so a translation cannot put markup on the page.
A key is a literal
Only a double-quoted string literal passed straight to t/1 is a key: t("Categories") is
registered when its template is saved, t(@label) cannot be — its value does not exist until the
page renders. The same limit Gettext has, for the same reason.
The site's own name
The site.title setting is a key of its site too: MishkaCmsCore.Runtime.Seo.site_name/2 reads
its translation for a page's head and share card. sync/1 registers it, and used_keys/1 counts
it, so Remove unused keeps it.
Where keys are read from
A component, page or layout asks for a key in its template and in the code of its
helpers, events and infos — t/1 is imported into all of them. source_keys/1 reads all
four, and it is the one rule that saving a record
(MishkaCmsCore.Runtime.Resources.Changes.RegisterTranslationKeys), used_keys/1, sync/1,
purge_unused/2, component_words/1 and the builder's Translations sheet use.
Summary
Functions
Gives the word key on site_id a row in language, as actor — publishing the language on the
site first when the site does not publish it yet, by adding it to that site's site.languages
(the global list, for a global word).
The languages add_language/4 could still give the word whose rows are held: the master's
allowed ones, else every language a site publishes — either way, less the ones it already has.
The words the components a template renders ask for, nested components included, by the site each
component belongs to: %{site_id => [key]}.
Deletes the key the row id belongs to, in every language, as actor — what Delete means on a
row of the admin list, which stands for the key rather than for one of its languages. Returns how
many rows went; the cache forgets each of them.
The key the row id belongs to, in every language, for the translate modal:
%{key, site_id, rows: [%{id, language, value}]}.
The keys a template asks for, in the order it first asks, each once.
languages(nil) as the admin's pickers want it: {"FA", "fa"}.
The languages the Add form offers, from the form's own state: a master's picked site's, every
site's for Global ("__nil__", Gervaz's nil option), nothing until a site is picked; a site
admin's own site's, the site field being a master's alone.
Each record's key in every language, for the admin list's chips: [%{language, translated?}] —
languages/1's order for its site first, then any other language it has a row in. One query for
the whole page of records, whatever their sites.
The languages a row for site_id is registered in and may be written in.
Whether actor may write site_id's words — a holder of the translation area
(admin:dashboard:runtime:translation): a master anywhere, a site admin on their own site and never the
global rows. The line add_language/4, group/2 and purge_unused/2 all hold.
The admin list's on_realtime: a change to any language of a key redraws the row standing for it
under the list's filters — read again, so its chips hold the change — and takes out the changed row
when it is not that one, so a save never adds a second row for the same word. A word new to the list
comes first, as a new row does in every other realtime list, and the list's total is read again.
The admin list's on_load: one row per key and site, among the rows its filters kept, whose
chips show every language of it.
An Ash validation: the language is one of languages/1 for the row's site.
Deletes the rows in scope that no component, page or layout asks for any more (used_keys/1),
as actor. For a site admin, :all and their own site both mean their own site, and any other
scope deletes nothing. Returns how many went, and the cache forgets them at once, so a key asked for again renders itself rather
than the word that was deleted.
Names the site this process is answering for, so translate/2 knows whose rows to read and
MishkaCmsCore.Runtime.LiveViewHelpers.component/1 whose components it may render.
Makes sure site_id has a row for every key in keys, in every language in languages/1.
The site put_site/1 named for this process, or nil when none was.
The process dictionary key put_site/1 writes, which
MishkaCmsCore.Runtime.LiveViewHelpers.carry_into_tasks/0 registers so a task a template starts
answers for the same site.
The fields of resource that source_keys/1 reads: :template and whichever of :helpers,
:events and :infos it has.
The keys a component, page or layout asks for — in its template and in the code of each of
its helpers, events and infos — in the order it first asks, each once. A field the record
does not have asks for nothing; an entry's code is read under :code or "code".
Registers what the components, pages and layouts in scope ask for (source_keys/1), each key
under its OWN record's site — exactly what saving each of them would have done. The backfill for
records written before t/1 existed, or before a site gained a language.
What t/1 returns: key in the language this process is answering in, else the key itself.
The ids of the rows purge_unused/2 would delete for actor in scope — what Remove unused
lists before anything is deleted.
Whether a row for site_id is still asked for: the keys (source_keys/1) of the components,
pages and layouts that render on that site — its own and the global ones — or, for a global row,
the keys of every one there is, since a global row serves every site.
The event a plugin names the keys it looks up on, beside its templates' (used_keys/1).
Functions
@spec add_language(map(), String.t() | nil, String.t(), String.t()) :: :ok | {:error, :forbidden | :not_allowed | :invalid}
Gives the word key on site_id a row in language, as actor — publishing the language on the
site first when the site does not publish it yet, by adding it to that site's site.languages
(the global list, for a global word).
Held to the same line as every other write here: a master may add to any site or to the global
list; a site admin to their own site alone, never the global list. And to the master's bounds: a
language outside Languages.allowed/0 is refused, as the settings page refuses it.
The languages add_language/4 could still give the word whose rows are held: the master's
allowed ones, else every language a site publishes — either way, less the ones it already has.
@spec component_words([{String.t(), String.t() | nil}]) :: %{ required(String.t() | nil) => [String.t()] }
The words the components a template renders ask for, nested components included, by the site each
component belongs to: %{site_id => [key]}.
refs are the template's own <.component> calls as {component_name, site_name} — a site of
nil or "Global" is a global component, as it is at render. Each level of nesting is one query,
and a component already seen is not read again, so a cycle ends.
@spec delete_key(map(), String.t()) :: {:ok, non_neg_integer()} | {:error, :not_found}
Deletes the key the row id belongs to, in every language, as actor — what Delete means on a
row of the admin list, which stands for the key rather than for one of its languages. Returns how
many rows went; the cache forgets each of them.
The key the row id belongs to, in every language, for the translate modal:
%{key, site_id, rows: [%{id, language, value}]}.
A language languages/1 names that has no row yet — the site gained it after the key was
registered — is registered first where actor may write, so the modal can offer it. Read as
actor: a row they may not read is {:error, :not_found}, as it would be in the table.
The keys a template asks for, in the order it first asks, each once.
Translations.keys(~s|<h2>{t("Categories")}</h2><a>{t("Tags")}</a>|)
#=> ["Categories", "Tags"]
languages(nil) as the admin's pickers want it: {"FA", "fa"}.
The languages the Add form offers, from the form's own state: a master's picked site's, every
site's for Global ("__nil__", Gervaz's nil option), nothing until a site is picked; a site
admin's own site's, the site field being a master's alone.
@spec language_states([MishkaCmsCore.Runtime.Translation.t()]) :: [ [%{language: String.t(), translated?: boolean()}] ]
Each record's key in every language, for the admin list's chips: [%{language, translated?}] —
languages/1's order for its site first, then any other language it has a row in. One query for
the whole page of records, whatever their sites.
The languages a row for site_id is registered in and may be written in.
A site's row takes the site's own list. A global row takes every language any site publishes in, and the global list besides: it serves every site, so a global component rendered on a Persian site asks for Persian whatever the global list says.
Read from the sites' own settings, not from the translations — a language nobody has translated into yet has no cached value to be found by. Two ETS reads per site, no query.
Translations.languages(site_id) #=> ["en", "fa"]
Translations.languages(nil) #=> ["de", "en", "fa"]
Whether actor may write site_id's words — a holder of the translation area
(admin:dashboard:runtime:translation): a master anywhere, a site admin on their own site and never the
global rows. The line add_language/4, group/2 and purge_unused/2 all hold.
@spec on_realtime(Ash.Notifier.Notification.t(), Phoenix.LiveView.Socket.t()) :: {:cont | :halt, Phoenix.LiveView.Socket.t()}
The admin list's on_realtime: a change to any language of a key redraws the row standing for it
under the list's filters — read again, so its chips hold the change — and takes out the changed row
when it is not that one, so a save never adds a second row for the same word. A word new to the list
comes first, as a new row does in every other realtime list, and the list's total is read again.
@spec one_row_per_key(Ash.Query.t(), map()) :: Ash.Query.t()
The admin list's on_load: one row per key and site, among the rows its filters kept, whose
chips show every language of it.
@spec published_language(Ash.Changeset.t(), map()) :: :ok | {:error, keyword()}
An Ash validation: the language is one of languages/1 for the row's site.
A translation into a language the site does not serve would never be rendered, and would be cached for nothing.
@spec purge_unused(map(), String.t() | nil | :all) :: non_neg_integer()
Deletes the rows in scope that no component, page or layout asks for any more (used_keys/1),
as actor. For a site admin, :all and their own site both mean their own site, and any other
scope deletes nothing. Returns how many went, and the cache forgets them at once, so a key asked for again renders itself rather
than the word that was deleted.
Obsolete rows are kept until someone asks for this, deliberately: deleting throws away a translation somebody wrote, and a renamed key is often renamed back.
@spec put_site(String.t() | nil) :: :ok
Names the site this process is answering for, so translate/2 knows whose rows to read and
MishkaCmsCore.Runtime.LiveViewHelpers.component/1 whose components it may render.
Called beside Gettext.put_locale/2 in MishkaCmsWeb.RuntimeLive and
MishkaCmsWeb.DraftPreviewLive, and by MishkaCmsWeb.ErrorHTML and the builder's canvas — every
process that renders runtime templates for one site. A render for one site among others goes
through MishkaCmsCore.Runtime.LiveViewHelpers.rendering_for/2 instead.
Makes sure site_id has a row for every key in keys, in every language in languages/1.
A row that already exists is left exactly as it is — see MishkaCmsCore.Runtime.Translation's
:register. Only the missing rows are written, and each is announced on the resource's PubSub
like any other create, so an admin list open on the words sees them arrive without a reload.
Registering what is already there writes, and announces, nothing.
@spec site() :: String.t() | nil
The site put_site/1 named for this process, or nil when none was.
@spec site_key() :: term()
The process dictionary key put_site/1 writes, which
MishkaCmsCore.Runtime.LiveViewHelpers.carry_into_tasks/0 registers so a task a template starts
answers for the same site.
@spec source_fields(Ash.Resource.t()) :: [atom()]
The fields of resource that source_keys/1 reads: :template and whichever of :helpers,
:events and :infos it has.
The keys a component, page or layout asks for — in its template and in the code of each of
its helpers, events and infos — in the order it first asks, each once. A field the record
does not have asks for nothing; an entry's code is read under :code or "code".
Translations.source_keys(%{
template: ~s|<h2>{t("Categories")}</h2>|,
events: [%{name: "save", code: ~s|{:noreply, put_flash(socket, :info, t("Saved"))}|}]
})
#=> ["Categories", "Saved"]
@spec sync(String.t() | nil | :all) :: :ok
Registers what the components, pages and layouts in scope ask for (source_keys/1), each key
under its OWN record's site — exactly what saving each of them would have done. The backfill for
records written before t/1 existed, or before a site gained a language.
scope is a site id, nil for the global templates, or :all.
What t/1 returns: key in the language this process is answering in, else the key itself.
Translations.translate("Categories") #=> "دستهبندیها"
Translations.translate("Read %{count} posts", count: 3) #=> "Read 3 posts"
The ids of the rows purge_unused/2 would delete for actor in scope — what Remove unused
lists before anything is deleted.
Whether a row for site_id is still asked for: the keys (source_keys/1) of the components,
pages and layouts that render on that site — its own and the global ones — or, for a global row,
the keys of every one there is, since a global row serves every site.
Read on demand rather than stored, so it cannot disagree with what the templates say now. It is what the admin's Used column asks; nothing on a visitor's path calls it.
A key no template holds, such as a word an extension's runtime:page:frame plugin looks up for
the bar under the header (MishkaCmsCore.Runtime.SiteLayout), is asked for on
used_keys_event/0 instead, with %{site_id: site_id, keys: []}: a plugin adds the keys it
looks up to keys, or "Remove unused" deletes them and what was written for them.
def call(%{keys: keys} = data), do: {:reply, %{data | keys: keys ++ ["Blog", "Posts"]}}
@spec used_keys_event() :: String.t()
The event a plugin names the keys it looks up on, beside its templates' (used_keys/1).