# `MishkaCmsCore.Runtime.Translations`
[🔗](https://github.com/mishka-group/mishka_cms_core/blob/v0.0.1/lib/mishka_cms_core/runtime/translations.ex#L5)

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.

# `add_language`

```elixir
@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`

```elixir
@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`

```elixir
@spec component_words([{String.t(), String.t() | nil}]) :: %{
  required(String.t() | nil) =&gt; [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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

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

# `language_options_for`

```elixir
@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`

```elixir
@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`

```elixir
@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?`

```elixir
@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`

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

# `one_row_per_key`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

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

# `site_key`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
