MishkaCmsCore.Runtime.Translations (Mishka CMS Core v0.0.1)

Copy Markdown View Source

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:

  1. this site's own row for the language being answered in
  2. the global row (site_id nil) for that language
  3. 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

add_language(actor, site_id, key, language)

@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.

addable_languages(held)

@spec addable_languages([String.t()]) :: [String.t()]

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.

component_words(refs)

@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.

delete_key(actor, id)

@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.

group(actor, id)

@spec group(map(), String.t()) :: {:ok, map()} | {:error, :not_found}

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.

keys(template)

@spec keys(term()) :: [String.t()]

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"]

language_options()

@spec language_options() :: [{String.t(), String.t()}]

languages(nil) as the admin's pickers want it: {"FA", "fa"}.

language_options_for(state)

@spec language_options_for(map()) :: [{String.t(), String.t()}]

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.

language_states(records)

@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.

languages(site_id)

@spec languages(String.t() | nil) :: [String.t()]

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"]

may_write?(actor, site_id)

@spec may_write?(map(), String.t() | nil) :: boolean()

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.

on_realtime(arg1, socket)

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.

one_row_per_key(query, state)

@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.

published_language(changeset, context)

@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.

purge_unused(actor, scope)

@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.

put_site(site_id)

@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.

register(site_id, keys)

@spec register(String.t() | nil, [String.t()]) :: :ok

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.

site()

@spec site() :: String.t() | nil

The site put_site/1 named for this process, or nil when none was.

site_key()

@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.

source_fields(resource)

@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.

source_keys(record)

@spec source_keys(map()) :: [String.t()]

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"]

sync(scope)

@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.

translate(key, bindings \\ [])

@spec translate(term(), keyword() | map()) :: String.t()

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"

unused_ids(actor, scope)

@spec unused_ids(map(), String.t() | nil | :all) :: [String.t()]

The ids of the rows purge_unused/2 would delete for actor in scope — what Remove unused lists before anything is deleted.

used_keys(site_id)

@spec used_keys(String.t() | nil) :: MapSet.t(String.t())

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"]}}

used_keys_event()

@spec used_keys_event() :: String.t()

The event a plugin names the keys it looks up on, beside its templates' (used_keys/1).