I18n Helpers are a set of tools to help you adding multilingual support to your Elixir application.
1. Ease the use of translations stored in database
- Translate your Ecto Schema structs (including all Schema associations, in one call)
post =
Repo.all(Post)
|> Repo.preload(:category)
|> Repo.preload(:comments)
|> Translator.translate("fr")
assert post.translated_title == "Le titre"
assert post.category.translated_name == "La catégorie"
assert List.first(post.comments).translated_text == "Un commentaire"
- Provide a fallback locale
Translator.translate(post, "nl", fallback_locale: "en")
- Handle missing translations (e.g. get notified)
Translator.translate(post, "en",
handle_missing_translation: fn translations_map, locale ->
# add here your error handling stuff,
# e.g. notify yourself that a translation is missing
end)
2. Render multilingual field inputs in your Phoenix Form
- Render multilanguage inputs to work with Ecto Schema structs that need translations
- Render multilanguage inputs in one call with custom labels and wrappers to customize design
3. Fetch the locale from the URL
- Assign the locale to the connection and set the Gettext locale
- Fetch the locale from the request path
e.g.example.com/en/hello
,example.com/fr/bonjour
, … - Fetch the locale from the subdomain
e.g.en.example.com/hello
,fr.example.com/bonjour
, … - Fetch the locale from the domain name
e.g.my-awesome-website.example/hello
,mon-super-site.example/bonjour
, … - Implement a custom locale fetcher
Translations must be stored in a JSON data type.
Note: if you prefer to store translations in separate database tables, then this library (at least the Ecto-related helpers) is not for you. Note however that, in my opinion, the pros having a JSON field compared to separate translation tables largely outweigh the cons; but I will not debate that here and let you Google that yourself to form your own opinion.
Each translatable field is stored in a map, where each key represents a locale and each value contains the text for that locale. Below is an example of such map:
%{
"en" => "My Favorite Books",
"fr" => "Mes Livres Préférés",
"nl" => "Mijn Lievelingsboeken",
"en-GB" => "My Favourite Books"
}
Let's first clarify something important in order to understand what this library actually helps with and what it does not.
Your translatable text field is essentially a map. In your schema, this translates to:
field :title, :map
Note: the
:map
type is actually wrapped by a custom Ecto type in order to clean empty translations from maps (more information and examples below).
Inserting/updating/deleting translations is not handled by this library, as nothing specific has to
be done to perform those with Ecto.Repo
on a :map
field.
What this library helps with, is extracting the translations from an Ecto struct and associated structs into virtual fields based on a given locale, fallback to a given fallback locale, and handling missing translations. See examples below.
defmodule MyApp.Post do
use Ecto.Schema
use I18nHelpers.Ecto.TranslatableFields
schema "posts" do
translatable_field :title
translatable_field :body
translatable_has_many :comments, MyApp.Comment
translatable_belongs_to :category, MyApp.Category
end
end
defmodule MyApp.Post do
@behaviour I18nHelpers.Ecto.TranslatableFields
use Ecto.Schema
schema "posts" do
field :title, :map
field :translated_title, :string, virtual: true
field :body, :map
field :translated_body, :string, virtual: true
has_many :comments, MyApp.Comment
belongs_to :category, MyApp.Category
end
def get_translatable_fields, do: [:title, :body]
def get_translatable_assocs, do: [:comments, :category]
end
When casting (Ecto.Changeset.cast/4
) translation maps, missing translations are omitted. For example
%{"en" => "My Favorite Books", "fr" => ""}
becomes
%{"en" => "My Favorite Books"}
If no translations are present in the map, casting converts the value to nil
:
%{"en" => "", "fr" => ""}
becomes
nil
You may import :i18n_helpers
's formatter configuration by importing
i18n_helpers
into your .formatter.exs
file (this allows for example to keep
translatable_field :title
without parentheses when running mix format
).
[
import_deps: [:ecto, :phoenix, :i18n_helpers],
#...
]
The translatable fields in your migration file should also be of :map
type:
add :title, :map, null: false
add :body, :map, null: false
You will typically translate Schema structs after retrieving them from the database:
alias I18nHelpers.Ecto.Translator
alias MyApp.{Post, Repo}
post =
Repo.all(Post)
|> Translator.translate("fr")
assert translated_post.translated_title == "Le titre"
assert translated_post.translated_body == "Le contenu"
assert translated_post.category.translated_name == "La catégorie"
Note above that all the associated Schema structs have been translated as well.
I prefer to perform translations in the Phoenix controller:
Blog.get_post!(post_id) # suppose Blog is the context managing posts, comments, etc.
|> Blog.with_comments_assocs()
|> Blog.with_category_assoc()
|> Translator.translate("fr")
Below is an example that more clearly shows the content of the structs and their translations:
alias I18nHelpers.Ecto.Translator
alias MyApp.{Category, Comment, Post}
comments = [
%Comment{text: %{"en" => "A comment", "fr" => "Un commentaire"}},
%Comment{text: %{"en" => "Another comment", "fr" => "Un autre commentaire"}}
]
category =
%Category{name: %{"en" => "The category", "fr" => "La catégorie"}}
post =
%Post{
title: %{"en" => "The title", "fr" => "Le titre"},
body: %{"en" => "The content", "fr" => "Le contenu"}
}
|> Map.put(:comments, comments)
|> Map.put(:category, category)
translated_post = Translator.translate(post, "fr")
assert translated_post.translated_title == "Le titre"
assert translated_post.translated_body == "Le contenu"
assert hd(translated_post.comments).translated_text == "Un commentaire"
assert translated_post.category.translated_name == "La catégorie"
You can also translate a single field:
title = Translator.translate(post.title, "fr") # post.title == %{"en" => "The title", "fr" => "Le titre"}
assert title == "Le titre"
If you do not specify the locale to translate to, the library will use the global Gettext default locale:
config :gettext, :default_locale, "fr" # in your `mix.exs` config file
title = Translator.translate(post.title)
assert title == "Le titre"
The global locale can be set through a Plug based on the website's host or path (see included plugs below).
A fallback locale can be given as an option. In the example below, we try to translate the title in Dutch, but no translation in Dutch has been provided. The translator will then use the given fallback locale:
title = Translator.translate(post.title, "nl", fallback_locale: "en")
assert title == "The title"
The default fallback locale is the global Gettext default locale.
In case a translation is missing, the translator returns an empty string:
post =
%Post{
title: %{"en" => "The title"},
body: %{"en" => "The content", "fr" => "Le contenu"}
}
translated_post = Translator.translate(post, "fr")
assert translated_post.translated_title == ""
If instead you want an error to raise when a translation is missing, you can use the
bang version of the translate function translate!/3
.
You may provide a callback to handle missing translations:
Translator.translate(%{"fr" => "bonjour"}, "en",
handle_missing_translation: fn translations_map, locale ->
# add here your error handling stuff,
# e.g. notify yourself that a translation is missing
assert translations_map == %{"fr" => "bonjour"}
assert locale == "en"
end
)
post = %Post{
title: %{"en" => "The title"},
body: %{"en" => "The content", "fr" => "Le contenu"}
}
Translator.translate(post, "fr",
handle_missing_field_translation: fn field, translations_map, locale ->
# add here your error handling stuff,
# e.g. notify yourself that a translation is missing
assert field == :title
assert translations_map == %{"en" => "The title"}
assert locale == "fr"
end
)
It can be quite tedious to pass your custom callback function to every translate/3
call; you can
avoid this by wrapping translate/3
in your own function, where you setup the commonly used
options. You can then import it for every controller through MyAppWeb.controller/0
. Below is an
example where we want to raise an error when a translation is not found:
defmodule MyTranslator do
alias I18nHelpers.Ecto.Translator
def translate(data_structure, locale \\ Gettext.get_locale(), opts \\ []) do
handle_missing_translation =
Keyword.get(opts, :handle_missing_translation, &handle_missing_translation/2)
opts =
Keyword.put(opts, :handle_missing_translation, handle_missing_translation)
Translator.translate(data_structure, locale, opts)
end
def handle_missing_translation(translations_map, locale) do
raise "missing translation for locale `#{locale}` in #{inspect(translations_map)}"
end
end
You may render form inputs for your translation maps using the usual Phoenix.HTML.Form
view helpers
as shown below:
<%= text_input f, :title_en, name: "post[title][en]", value: Map.get(f.data.title, "en", "") %>
However code written in templates should be simple and easier to read. This library provides view
helpers that allow writing form input fields in a more concise and clean way. Open up the entrypoint
for defining your web interface, such as MyAppWeb
, and add the line below into the view
function's
quote
block.
def view do
quote do
# some code
import I18nHelpers.HTML.InputHelpers
end
end
Helpers below render a single input:
<%= translated_text_input f, :title, :en %>
<%= translated_textarea f, :title, :en %>
You may also render all the inputs (for all languages) for a field in one line:
translated_text_inputs(f, :title, [:en, :fr])
translated_text_inputs(f, :title, MyApp.Gettext) # will call Gettext.known_locales/1 on given Gettext backend
translated_textareas(f, :title, [:en, :fr])
If you need custom labels and styling, you may pass options allowing you to add labels and wrap the generated inputs with custom HTML elements:
translated_text_inputs(f, :title, [:en, :fr],
labels: fn locale -> content_tag(:i, locale) end,
wrappers: fn _locale -> {:div, class: "translated-input-wrapper"} end
)
The library provides a set of plugs with different strategies to fetch the locale from the URL.
The plug will assign the locale to the Connection and set the Gettext locale.
You can retrieve the locale from the request path:
plug I18nHelpers.Plugs.PutLocaleFromPath,
allowed_locales: ["en", "fr"],
default_locale: "en"
See tests below:
alias I18nHelpers.Plugs.PutLocaleFromPath
options = PutLocaleFromPath.init(allowed_locales: ["fr", "nl"], default_locale: "en")
conn = conn(:get, "https://example.com/fr/bonjour")
conn = PutLocaleFromPath.call(conn, options)
assert conn.assigns == %{locale: "fr"}
assert Gettext.get_locale == "fr"
conn = conn(:get, "https://example.com/hello") # locale is not specified in path, use `default_locale`
conn = PutLocaleFromPath.call(conn, options)
assert conn.assigns == %{locale: "en"}
assert Gettext.get_locale == "en"
Or from the subdomain:
plug I18nHelpers.Plugs.PutLocaleFromSubdomain,
allowed_locales: ["en", "fr"],
default_locale: "en"
Tests:
alias I18nHelpers.Plugs.PutLocaleFromSubdomain
options = PutLocaleFromSubdomain.init(allowed_locales: ["en", "fr"], default_locale: "en")
conn = conn(:get, "https://fr.example.com/bonjour")
conn = PutLocaleFromSubdomain.call(conn, options)
assert conn.assigns == %{locale: "fr"}
assert Gettext.get_locale == "fr"
conn = conn(:get, "https://example.com/hello") # locale is not specified in subdomain, use `default_locale`
conn = PutLocaleFromSubdomain.call(conn, options)
assert conn.assigns == %{locale: "en"}
assert Gettext.get_locale == "en"
Or from the domain:
plug I18nHelpers.Plugs.PutLocaleFromDomain,
domains_locales_map: %{
"my-awesome-website.example" => "en",
"mon-super-site.example" => "fr"
},
allowed_locales: ["en", "fr"],
default_locale: "en"
Tests:
alias I18nHelpers.Plugs.PutLocaleFromDomain
options =
PutLocaleFromDomain.init(
domains_locales_map: %{
"my-awesome-website.example" => "en",
"mon-super-site.example" => "fr"
},
allowed_locales: ["en", "fr"],
default_locale: "en"
)
conn = conn(:get, "https://my-awesome-website.example/hello")
conn = PutLocaleFromDomain.call(conn, options)
assert conn.assigns == %{locale: "en"}
assert Gettext.get_locale == "en"
conn = conn(:get, "https://mon-super-site.example/bonjour")
conn = PutLocaleFromDomain.call(conn, options)
assert conn.assigns == %{locale: "fr"}
assert Gettext.get_locale == "fr"
conn = conn(:get, "https://another-domain.example/hello") # domain not found in `domains_locales_map`, use `default_locale`
conn = PutLocaleFromDomain.call(conn, options)
assert conn.assigns == %{locale: "en"}
assert Gettext.get_locale == "en"
or from your custom function:
alias I18nHelpers.Plugs.PutLocale
defp find_locale(conn) do
case conn.host do
"en.example.com" ->
"en"
"nl.example.com" ->
"nl"
_ ->
case conn.path_info do
["en" | _] -> "en"
["nl" | _] -> "nl"
_ -> "en"
end
end
end
options = PutLocale.init(find_locale: &find_locale/1)
conn = conn(:get, "/nl/hallo")
conn = PutLocale.call(conn, options)
assert conn.assigns == %{locale: "nl"}
assert Gettext.get_locale == "nl"
Add i18n_helpers
for Elixir as a dependency in your mix.exs
file:
def deps do
[
{:i18n_helpers, "~> 0.14"}
]
end
HexDocs documentation can be found at https://hexdocs.pm/i18n_helpers.