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
- Have an extension with a provision, the code that sets the extension up on a site, and a plugin that looks up words, such as one that draws the bar under the header. See Extensions and plugins, Page frame: languages and the section bar and Plugin events.
- Know how
t()words and the Translations screen work. See Translate interface words with t(). - Words belong to one site and one language. See Languages and language editions.
- For the last step, have a checkout of
mishka_cms_corewith your extension beside it.
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:
defmodule Notes.Plugins.PageFrame do
@doc "The words the bar asks the site's translations for."
def words, do: ["Notes", "Archive"]
endCall MishkaCmsCore.Runtime.Translations.register/2 at the end of the provision. It takes the site's id and a list of keys:
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
endsite_idis the site's id, ornilfor 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/2writes one row for each key in each language of the site. Fornil, it uses every language that any site publishes in, plus the global list.- A new row has an empty value.
t/1shows 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
:okin 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:
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}
endAdd 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:
alias MishkaCmsCore.Runtime.States.TranslationsState
defp word(%{site_id: site_id, language: language}, key),
do: TranslationsState.lookup(site_id, language, key) || keysite_idis the site's id ornil,languageis a language code such as"fr", andkeyis 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:
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| Command | What it does |
|---|---|
mix gettext.extract --merge --no-fuzzy | Recompiles 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.gervaz | Adds 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.extensions | Adds 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-fuzzy | Merges 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:
mix mishka_cms_core.gettext.extensions ../mishka_blog ../mishka_document ../notesIn 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:
mix mishka_cms_core.gettext.extensions --core ../mishka_cms_coreA 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:
Translations.register(site.id, ["Notes", "Archive"])
#=> :ok
"Notes" in Translations.used_keys(site.id)
#=> trueWithout 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:
TranslationsState.lookup(site.id, "fr", "Notes")
#=> "Carnets"
TranslationsState.lookup(site.id, "en", "Notes")
#=> nilNext steps
- Translate interface words with t(): the words in templates, and the Translations screen.
- Plugin events: every event the CMS fires, with its payload.
- Languages and language editions: how a site's languages, editions and words fit together.
- Draw an extension's pages in the site layout: hang the pages that use these words on
site-main.