Translate an extension's words

v0.0.1

Some of an extension's words never sit in a template. The labels of the bar under the header, for one, come from an Elixir plugin. The CMS finds a word by reading templates, so it cannot find these on its own. This guide registers an extension's words for a site, keeps them when someone clicks Remove unused, reads them back in a plugin, and runs the gettext commands for the words in the extension's Elixir source.

Before you begin

Register the words from your provision

The example uses a Notes extension. A plugin that draws its bar looks up two words, and the plugin lists them:

elixir
defmodule Notes.Plugins.PageFrame do
  @doc "The words the bar asks the site's translations for."
  def words, do: ["Notes", "Archive"]
end

Call MishkaCmsCore.Runtime.Translations.register/2 at the end of the provision. It takes the site's id and a list of keys:

elixir
def ensure!(site, answers \\ %{}, opts \\ []) do
  report = ExtensionProvision.ensure!(manifest(), site, answers, opts)

  # A plugin asks for these words, not a template, so register them here.
  :ok = Translations.register(site.id, Notes.Plugins.PageFrame.words())

  report
end
  • site_id is the site's id, or nil for global words that every site shares.
  • A key is a string of 1 to 500 characters. It is the text a visitor sees until someone translates it.
  • register/2 writes one row for each key in each language of the site. For nil, it uses every language that any site publishes in, plus the global list.
  • A new row has an empty value. t/1 shows the key until a translator saves a value.
  • A row that exists stays as it is, translation included. Registering the same keys again writes nothing. The function returns :ok in every case, an empty list included.

The rows appear on the Translations screen. When someone sets the extension up again, the provision runs again, so a site that gained a language since gets the rows for it.

Keep the words when someone clicks Remove unused

The Used column of the Translations screen reads In use for a word that a component, a page or a layout of the site asks for with t("…"). Remove unused opens Unused words, the words nothing asks for, and Delete all removes them. A word that only a plugin looks up is in no template, so the screen calls it unused.

To keep your words, name them on the runtime:translations:used_keys event. Translations.used_keys/1 fires it each time it works out which words a site uses, with the payload %{site_id: site_id, keys: []}. site_id is the site, or nil for the global words. Add your keys to keys and return the map:

elixir
defmodule Notes.Plugins.TranslationKeys do
  use MishkaInstaller.Event.Hook,
    event: "runtime:translations:used_keys",
    initial: %{priority: 50}

  @impl true
  def call(%{keys: keys} = data) when is_list(keys),
    do: {:reply, %{data | keys: keys ++ Notes.Plugins.PageFrame.words()}}

  def call(data), do: {:reply, data}
end

Add the plugin to the plugins your application supervises, as for any plugin (see Extensions and plugins). A plugin missing from that list never registers. The CMS ignores an entry of keys that is not a string, and an answer that is not a map with a list in keys adds nothing. Mishka Blog's MishkaBlog.Plugins.TranslationKeys and Mishka Document's MishkaDocument.Plugins.UsedTranslationKeys have the same shape.

Read a word in your plugin

MishkaCmsCore.Runtime.States.TranslationsState.lookup(site_id, language, key) returns the translation of key in language, or nil:

elixir
alias MishkaCmsCore.Runtime.States.TranslationsState

defp word(%{site_id: site_id, language: language}, key),
  do: TranslationsState.lookup(site_id, language, key) || key
  • site_id is the site's id or nil, language is a language code such as "fr", and key is the text you registered.
  • It returns the site's own value first. When the site has none, it returns the global value. When neither exists, it returns nil. It never returns the key.
  • A word that is registered and not yet translated returns nil, because the cache holds only rows that have a value. Add || key, as above, to show the key.
  • It reads the cache in memory, runs no query and never raises. Before the cache starts, it returns nil.

In a template, t/1 is Translations.translate/2. It calls lookup/3 with the site that Translations.put_site/1 named for the process and the process's Gettext locale. It shows the key when lookup/3 returns nil, then fills each %{name} from the bindings. A plugin runs outside a template, so it passes the site and the language itself. A plugin on runtime:page:frame finds both in its payload as site_id and language.

Add rows that Scan templates leaves out

Scan templates reads the t("…") calls of every component, page and layout again and adds the rows that are missing. It does not register an extension's words, because no template holds them. To add their rows, set the extension up again so the provision runs, or call Translations.register/2. Opening a word's Translate dialog also adds a row for each language the site gained since the word was registered.

Translate the words in your extension's Elixir code

Words in an extension's Elixir source, in gettext("…") and its relatives, live in catalogue files. An extension writes use Gettext, backend: MishkaCmsCore.Gettext, so these words are translated from the core's catalogue, priv/gettext in mishka_cms_core. The catalogue holds the .pot templates and en/LC_MESSAGES/*.po. A message with no translation in the reader's locale reads as its own text.

mix gettext.extract finds only the core's own messages. Two more commands add the messages of MishkaGervaz and of the extensions. Run all four in a checkout of mishka_cms_core, in this order, with the extensions checked out beside it:

bash
mix gettext.extract --merge --no-fuzzy
mix mishka_cms_core.gettext.gervaz
mix mishka_cms_core.gettext.extensions
mix gettext.merge priv/gettext --no-fuzzy
CommandWhat it does
mix gettext.extract --merge --no-fuzzyRecompiles the core, writes its messages to the .pot templates, one for each domain, and merges the templates into the .po files. --no-fuzzy goes on to the merge and turns off fuzzy matching.
mix mishka_cms_core.gettext.gervazAdds every message of MishkaGervaz's .pot files to the core's .pot file of the same domain. A host sets config :mishka_gervaz, :gettext_backend, MishkaCmsCore.Gettext, so MishkaGervaz's words are translated from the core's .po files.
mix mishka_cms_core.gettext.extensionsAdds every message the extensions mark with MishkaCmsCore.Gettext to the core's .pot file of its domain. With no arguments it reads ../mishka_blog and ../mishka_document. Give paths to read other extensions.
mix gettext.merge priv/gettext --no-fuzzyMerges the updated templates into the .po files of every locale under priv/gettext, with no fuzzy matching.

To read your extension, give its path. The paths you give replace the two defaults, so list Mishka Blog and Mishka Document too:

bash
mix mishka_cms_core.gettext.extensions ../mishka_blog ../mishka_document ../notes

In each extension the command runs the same task again, in the dev environment, with --core naming the core. That run compiles the extension with Gettext's extractor on. It writes what gettext, dgettext, ngettext, the *_noop macros and the other Gettext macros mark with MishkaCmsCore.Gettext into the core's templates. That covers every domain, plural forms, and the text of a HEEx template or a MishkaGervaz DSL option. Run by hand in an extension, the task writes into the :mishka_cms_core dependency, or into the core that --core names:

bash
mix mishka_cms_core.gettext.extensions --core ../mishka_cms_core

A message the core also writes is left as it is. Any other message keeps its references into MishkaGervaz and the extensions. The core's :excluded_refs_from_purging setting makes the next mix gettext.extract keep them, so mix gettext.extract --check-up-to-date still passes. A message that neither MishkaGervaz nor an extension has any longer is removed.

The catalogue starts with English only. To add a language, run mix gettext.merge priv/gettext --locale xx and translate the .po files it creates.

Example: the Notes words

With Notes.Plugins.TranslationKeys running, register the two words and ask whether the site uses them:

elixir
Translations.register(site.id, ["Notes", "Archive"])
#=> :ok

"Notes" in Translations.used_keys(site.id)
#=> true

Without the plugin, the second expression returns false, and Remove unused lists both words in every language the site publishes in. On a site that publishes in English and French, a translator saves Carnets as the French value of Notes. A lookup then returns it for French and nil for English:

elixir
TranslationsState.lookup(site.id, "fr", "Notes")
#=> "Carnets"

TranslationsState.lookup(site.id, "en", "Notes")
#=> nil

Next steps