Translate labels and messages

v0.0.1

Every word Gervaz draws goes through Gettext: the library's own wording, such as "Load More" and "Retry", and the labels, prompts and messages you write in the DSL. This guide points Gervaz at your Gettext backend, marks the strings that only the DSL holds so that mix gettext.extract finds them, and carries the locale into work that runs in another process. The examples come from the tags, redirects and layouts resources of Mishka CMS.

Before you begin

  • Have a Gettext backend in your application, with its own priv/gettext folder.
  • Know the two domains. Gervaz uses the mishka_gervaz domain for everything, except the messages a form field type refuses a value with and the Ash error messages it shows, which use the errors domain.

Point Gervaz at your backend

Gervaz ships its own backend, MishkaGervaz.Gettext, with templates in priv/gettext. To translate through your backend instead, set it in the config:

elixir
config :mishka_gervaz, :gettext_backend, MyApp.Gettext

Then give your translators both templates of the library, priv/gettext/mishka_gervaz.pot and priv/gettext/errors.pot, merged into the templates of your backend. The files for a locale are priv/gettext/<locale>/LC_MESSAGES/mishka_gervaz.po and errors.po. Mishka CMS does the merge with a Mix task that reads the .pot files from the Gervaz dependency and adds their messages to its own, after mix gettext.extract.

Write labels as functions

Label options such as label, placeholder and description accept a string or a zero-arity function. Prefer the function, because it runs in the locale of the page when the label is drawn:

elixir
use MishkaGervaz.Messages, backend: MishkaCmsCore.Gettext

column :name do
  label fn -> dgettext("mishka_gervaz", "Name") end
end

use MishkaGervaz.Messages injects the Gettext macros into the module. With backend: it uses that backend, which lets mix gettext.extract find the strings. Without it, the macros use the :gettext_backend of the config, or MishkaGervaz.Gettext.

Mark strings that only the DSL holds

Gervaz translates every plain string the DSL holds when it draws it, in the locale of the caller. That covers labels, placeholders, prompts, {label, value} option labels, confirm messages, empty and error states, notices, headers and footers. A function value is called as it is and is not translated again.

A plain string is not found by mix gettext.extract unless it is marked. Use dgettext_noop, which returns the English string and lets Gervaz translate it at draw time:

elixir
empty_state message: dgettext_noop("mishka_gervaz", "No redirects yet"), icon: "hero-link"

filter :status, :select do
  ui do
    prompt dgettext_noop("mishka_gervaz", "Any status")
  end
end

bulk_actions do
  action :archive,
    handler: :destroy,
    confirm: dgettext_noop("mishka_gervaz", "Archive selected redirects?")
end

A string with %{...} in it is returned as it is. Fill its values with a function: fn -> dgettext("mishka_gervaz", "...", name: name) end.

Translate option lists

An option list can hold labels as plain strings or as translated ones. The list may be a function that runs when the options load, so it can call dgettext/2 directly:

elixir
field :status do
  options fn ->
    [
      {dgettext("mishka_gervaz", "Draft"), :draft},
      {dgettext("mishka_gervaz", "Published"), :published}
    ]
  end
end

Keep the locale in work that runs elsewhere

A process you start has no Gettext locale of its own. A table or form loads in the locale of its page, but your own asynchronous work that returns words starts in the default locale. Use these functions in place of the plain ones:

  • MishkaGervaz.Messages.start_async/4 in place of Phoenix.LiveView.start_async/4.
  • MishkaGervaz.Messages.assign_async/4 in place of Phoenix.LiveView.assign_async/4.
  • MishkaGervaz.Messages.in_caller_locale/1 to wrap a function of arity 0 or 1 that you give to Task.async/1 or Task.async_stream/3.

The tags page loads the version history this way:

elixir
socket
|> assign(:versions_loading, true)
|> MishkaGervaz.Messages.start_async(:load_versions, fn ->
  ...
end)

All three also carry the values of the process dictionary keys that you register with MishkaGervaz.Messages.carry_process_keys/1, for work that reads, say, the tenant its caller put in the process dictionary. No key is registered by default.

Next steps