├── .github └── FUNDING.yml ├── test ├── test_helper.exs ├── plugs │ ├── put_locale_test.exs │ ├── put_locale_from_path_test.exs │ ├── put_locale_from_subdomain_test.exs │ └── put_locale_from_domain_test.exs ├── html │ └── input_helpers_test.exs └── ecto │ └── translator_test.exs ├── priv └── gettext │ └── fr │ └── LC_MESSAGES │ └── default.po ├── .formatter.exs ├── .gitignore ├── lib ├── plugs │ ├── put_locale.ex │ ├── put_locale_from_subdomain.ex │ ├── put_locale_from_path.ex │ ├── put_locale_from_domain.ex │ └── put_locale_from_conn.ex ├── ecto │ ├── translatable_type.ex │ ├── translatable_fields.ex │ └── translator.ex └── html │ └── input_helpers.ex ├── mix.exs ├── mix.lock ├── LICENSE └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: mathieuprog 2 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /priv/gettext/fr/LC_MESSAGES/default.po: -------------------------------------------------------------------------------- 1 | msgid "Hello world!" 2 | msgstr "Bonjour monde!" -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | locals_without_parens = [ 2 | translatable_field: 1, 3 | translatable_belongs_to: 2, 4 | translatable_belongs_to: 3, 5 | translatable_has_many: 2, 6 | translatable_has_many: 3, 7 | translatable_has_one: 2, 8 | translatable_has_one: 3, 9 | translatable_many_to_many: 2, 10 | translatable_many_to_many: 3 11 | ] 12 | 13 | [ 14 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 15 | locals_without_parens: locals_without_parens, 16 | export: [ 17 | locals_without_parens: locals_without_parens 18 | ] 19 | ] 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IntelliJ IDEA 2 | /.idea/ 3 | *.iml 4 | 5 | # The directory Mix will write compiled artifacts to. 6 | /_build/ 7 | 8 | # If you run "mix test --cover", coverage assets end up here. 9 | /cover/ 10 | 11 | # The directory Mix downloads your dependencies sources to. 12 | /deps/ 13 | 14 | # Where third-party dependencies like ExDoc output generated docs. 15 | /doc/ 16 | 17 | # Ignore .fetch files in case you like to edit your project deps locally. 18 | /.fetch 19 | 20 | # If the VM crashes, it generates a dump, let's ignore it too. 21 | erl_crash.dump 22 | 23 | # Also ignore archive artifacts (built via "mix archive.build"). 24 | *.ez 25 | 26 | # Ignore package tarball (built via "mix hex.build"). 27 | i18n_helpers-*.tar 28 | 29 | -------------------------------------------------------------------------------- /lib/plugs/put_locale.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Plug) do 2 | defmodule I18nHelpers.Plugs.PutLocale do 3 | @moduledoc false 4 | 5 | import Plug.Conn 6 | 7 | @spec init(keyword) :: keyword 8 | def init(options) do 9 | Keyword.get(options, :find_locale) || 10 | raise ArgumentError, "must supply `find_locale` option" 11 | 12 | options 13 | end 14 | 15 | @spec call(Plug.Conn.t(), keyword) :: Plug.Conn.t() 16 | def call(conn, options) do 17 | find_locale = Keyword.fetch!(options, :find_locale) 18 | backend = Keyword.get(options, :backend) 19 | 20 | locale = 21 | find_locale.(conn) || 22 | raise "locale not found in conn #{inspect(conn)}" 23 | 24 | if backend, 25 | do: Gettext.put_locale(backend, locale), 26 | else: Gettext.put_locale(locale) 27 | 28 | assign(conn, :locale, locale) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/ecto/translatable_type.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Ecto) do 2 | defmodule I18nHelpers.Ecto.TranslatableType do 3 | use Ecto.Type 4 | 5 | # data type we want to use to store our custom value at the database level 6 | def type, do: :map 7 | 8 | # takes a value from an external source (for example a user input) and 9 | # converts it into a format that Ecto can work with 10 | def cast(translations) when translations == %{}, do: {:ok, nil} 11 | 12 | def cast(%{} = translations) do 13 | translations_without_empty = 14 | translations 15 | |> Enum.reject(fn {_, v} -> String.trim(v) == "" end) 16 | |> Map.new() 17 | |> (fn 18 | map when map == %{} -> nil 19 | map -> map 20 | end).() 21 | 22 | {:ok, translations_without_empty} 23 | end 24 | 25 | def cast(nil), do: {:ok, nil} 26 | def cast(_), do: :error 27 | 28 | # converts the raw value pulled from the database into an Elixir value 29 | def load(term), do: Ecto.Type.load(:map, term) 30 | 31 | # takes an Elixir value and converts it into a value that the database recognizes 32 | def dump(term), do: Ecto.Type.dump(:map, term) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/plugs/put_locale_test.exs: -------------------------------------------------------------------------------- 1 | defmodule I18nHelpers.Plugs.PutLocaleTest do 2 | use ExUnit.Case, async: true 3 | 4 | use Plug.Test 5 | 6 | alias I18nHelpers.Plugs.PutLocale 7 | 8 | doctest PutLocale 9 | 10 | test "init PutLocale plug" do 11 | assert_raise ArgumentError, ~r"must supply `find_locale` option", fn -> 12 | PutLocale.init([]) 13 | end 14 | end 15 | 16 | defp find_locale(conn) do 17 | case conn.host do 18 | "en.example.com" -> 19 | "en" 20 | 21 | "nl.example.com" -> 22 | "nl" 23 | 24 | _ -> 25 | case conn.path_info do 26 | ["en" | _] -> "en" 27 | ["nl" | _] -> "nl" 28 | _ -> "en" 29 | end 30 | end 31 | end 32 | 33 | test "find_locale/1 custom function" do 34 | options = PutLocale.init(find_locale: &find_locale/1) 35 | 36 | conn = conn(:get, "/hello") 37 | conn = PutLocale.call(conn, options) 38 | 39 | assert conn.assigns == %{locale: "en"} 40 | 41 | conn = conn(:get, "/nl/hallo") 42 | conn = PutLocale.call(conn, options) 43 | 44 | assert conn.assigns == %{locale: "nl"} 45 | 46 | conn = conn(:get, "https://en.example.com/hello") 47 | conn = PutLocale.call(conn, options) 48 | 49 | assert conn.assigns == %{locale: "en"} 50 | 51 | conn = conn(:get, "https://nl.example.com/hallo") 52 | conn = PutLocale.call(conn, options) 53 | 54 | assert conn.assigns == %{locale: "nl"} 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/plugs/put_locale_from_subdomain.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Plug) do 2 | defmodule I18nHelpers.Plugs.PutLocaleFromSubdomain do 3 | @moduledoc """ 4 | Plug to fetch the locale from the URL's subdomain; 5 | assigns the locale to the Connection and sets the Gettext locale. 6 | 7 | This plug is useful if you have URLs similar to: 8 | 9 | https://fr.example.com/bonjour 10 | https://nl.example.com/hallo 11 | https://es.example.com/hola 12 | https://example.com/hello (default locale "en") 13 | 14 | ## Options 15 | 16 | * `:default_locale` - locale to be used if no locale was found in the URL 17 | * `:allowed_locales` - a list of allowed locales. If no locale was found, 18 | use the `:default locale` if specified, otherwise raise an error. 19 | 20 | """ 21 | 22 | alias I18nHelpers.Plugs.PutLocaleFromConn 23 | 24 | @spec init(keyword) :: keyword 25 | def init(options) do 26 | options = 27 | Keyword.put(options, :find_locale, fn conn -> 28 | List.first(String.split(conn.host, ".")) 29 | end) 30 | 31 | options = 32 | Keyword.put(options, :handle_missing_locale, fn conn -> 33 | raise "locale not found in host #{conn.host}" 34 | end) 35 | 36 | PutLocaleFromConn.init(options) 37 | end 38 | 39 | @spec call(Plug.Conn.t(), keyword) :: Plug.Conn.t() 40 | def call(conn, options) do 41 | PutLocaleFromConn.call(conn, options) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/plugs/put_locale_from_path.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Plug) do 2 | defmodule I18nHelpers.Plugs.PutLocaleFromPath do 3 | @moduledoc """ 4 | Plug to fetch the locale from the first segment of the URL's request path; 5 | assigns the locale to the Connection and sets the Gettext locale. 6 | 7 | This plug is useful if you have URLs similar to: 8 | 9 | https://example.com/fr/bonjour 10 | https://example.com/nl/hallo 11 | https://example.com/es/hola 12 | https://example.com/hello (default locale "en") 13 | 14 | ## Options 15 | 16 | * `:default_locale` - locale to be used if no locale was found in the URL 17 | * `:allowed_locales` - a list of allowed locales. If no locale was found, 18 | use the `:default locale` if specified, otherwise raise an error. 19 | 20 | """ 21 | 22 | alias I18nHelpers.Plugs.PutLocaleFromConn 23 | 24 | @spec init(keyword) :: keyword 25 | def init(options) do 26 | options = 27 | Keyword.put(options, :find_locale, fn conn -> 28 | List.first(conn.path_info) 29 | end) 30 | 31 | options = 32 | Keyword.put(options, :handle_missing_locale, fn conn -> 33 | raise "locale not found in path #{conn.request_path}" 34 | end) 35 | 36 | PutLocaleFromConn.init(options) 37 | end 38 | 39 | @spec call(Plug.Conn.t(), keyword) :: Plug.Conn.t() 40 | def call(conn, options) do 41 | PutLocaleFromConn.call(conn, options) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule I18nHelpers.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.14.0" 5 | 6 | def project do 7 | [ 8 | app: :i18n_helpers, 9 | elixir: "~> 1.9", 10 | deps: deps(), 11 | elixirc_paths: elixirc_paths(Mix.env()), 12 | 13 | # Hex 14 | version: @version, 15 | package: package(), 16 | description: "A set of tools to help you translate your Elixir applications", 17 | 18 | # ExDoc 19 | name: "I18n Helpers", 20 | source_url: "https://github.com/mathieuprog/i18n_helpers", 21 | docs: docs() 22 | ] 23 | end 24 | 25 | def application do 26 | [ 27 | extra_applications: [:logger] 28 | ] 29 | end 30 | 31 | defp deps do 32 | [ 33 | {:gettext, "~> 0.26"}, 34 | {:phoenix_html_helpers, "~> 1.0"}, 35 | {:ecto, "~> 3.12", optional: true}, 36 | {:phoenix_html, "~> 4.1", optional: true}, 37 | {:plug, "~> 1.9 or ~> 1.16", optional: true}, 38 | {:ex_doc, "~> 0.34", only: :dev}, 39 | {:inch_ex, "~> 2.0", only: :dev}, 40 | {:dialyxir, "~> 1.4", only: :dev} 41 | ] 42 | end 43 | 44 | defp elixirc_paths(:test), do: ["lib", "test/support"] 45 | defp elixirc_paths(_), do: ["lib"] 46 | 47 | defp package do 48 | [ 49 | licenses: ["Apache 2.0"], 50 | maintainers: ["Mathieu Decaffmeyer"], 51 | links: %{"GitHub" => "https://github.com/mathieuprog/i18n_helpers"} 52 | ] 53 | end 54 | 55 | defp docs do 56 | [ 57 | main: "readme", 58 | extras: ["README.md"], 59 | source_ref: "v#{@version}" 60 | ] 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/plugs/put_locale_from_domain.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Plug) do 2 | defmodule I18nHelpers.Plugs.PutLocaleFromDomain do 3 | @moduledoc """ 4 | Plug to fetch the locale from the URL's subdomain; 5 | assigns the locale to the Connection and sets the Gettext locale. 6 | 7 | This plug is useful if you have URLs similar to: 8 | 9 | https://mon-super-site.example/bonjour 10 | https://mijn-geweldige-website.example/hallo 11 | https://mi-gran-sitio.example/hola 12 | https://my-awesome-website.example/hello 13 | 14 | ## Options 15 | 16 | * `:domains_locales_map` - map where each key represents a domain and each 17 | value contains the locale to be used for that domain 18 | * `:default_locale` - locale to be used if no locale was found in the URL 19 | * `:allowed_locales` - a list of allowed locales. If no locale was found, 20 | use the `:default locale` if specified, otherwise raise an error. 21 | 22 | `:domains_locales_map` is a mandatory option. Below is an example of a map 23 | it can hold: 24 | 25 | %{ 26 | "mon-super-site.example" => "fr", 27 | "mijn-geweldige-website.example" => "nl", 28 | "mi-gran-sitio.example" => "es", 29 | "my-awesome-website.example" => "en" 30 | } 31 | 32 | """ 33 | 34 | alias I18nHelpers.Plugs.PutLocaleFromConn 35 | 36 | @spec init(keyword) :: keyword 37 | def init(options) do 38 | domains_locales_map = 39 | Keyword.get(options, :domains_locales_map) || 40 | raise ArgumentError, "must supply `domains_locales_map` option" 41 | 42 | options = 43 | Keyword.put(options, :find_locale, fn conn -> 44 | Enum.find_value(domains_locales_map, fn {domain, locale} -> 45 | locale = to_string(locale) 46 | 47 | cond do 48 | String.contains?(conn.host, domain) -> locale 49 | true -> nil 50 | end 51 | end) 52 | end) 53 | 54 | options = 55 | Keyword.put(options, :handle_missing_locale, fn conn -> 56 | raise "locale not found in host #{conn.host}" 57 | end) 58 | 59 | PutLocaleFromConn.init(options) 60 | end 61 | 62 | @spec call(Plug.Conn.t(), keyword) :: Plug.Conn.t() 63 | def call(conn, options) do 64 | PutLocaleFromConn.call(conn, options) 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /test/plugs/put_locale_from_path_test.exs: -------------------------------------------------------------------------------- 1 | defmodule I18nHelpers.Plugs.PutLocaleFromPathTest do 2 | use ExUnit.Case, async: true 3 | 4 | use Plug.Test 5 | 6 | alias I18nHelpers.Plugs.PutLocaleFromPath 7 | 8 | doctest PutLocaleFromPath 9 | 10 | test "init PutLocaleFromPath plug" do 11 | assert_raise ArgumentError, 12 | ~r"`default_locale` is not included in `allowed_locales` option", 13 | fn -> 14 | PutLocaleFromPath.init(allowed_locales: ["fr", "nl"], default_locale: "en") 15 | end 16 | end 17 | 18 | test "PutLocaleFromPath plug without options" do 19 | options = PutLocaleFromPath.init([]) 20 | 21 | conn = conn(:get, "/hello") 22 | 23 | assert_raise RuntimeError, ~r"locale not found in path /hello", fn -> 24 | PutLocaleFromPath.call(conn, options) 25 | end 26 | 27 | conn = conn(:get, "/fr/bonjour") 28 | conn = PutLocaleFromPath.call(conn, options) 29 | 30 | assert conn.assigns == %{locale: "fr"} 31 | assert Gettext.get_locale() == "fr" 32 | 33 | conn = conn(:get, "/fr-BE/bonjour") 34 | conn = PutLocaleFromPath.call(conn, options) 35 | 36 | assert conn.assigns == %{locale: "fr-BE"} 37 | end 38 | 39 | test "PutLocaleFromPath plug options" do 40 | options = PutLocaleFromPath.init(default_locale: "en") 41 | 42 | conn = conn(:get, "/hello") 43 | conn = PutLocaleFromPath.call(conn, options) 44 | 45 | assert conn.assigns == %{locale: "en"} 46 | 47 | options = PutLocaleFromPath.init(allowed_locales: [:en, "fr"]) 48 | 49 | conn = conn(:get, "/en/hello") 50 | conn = PutLocaleFromPath.call(conn, options) 51 | 52 | assert conn.assigns == %{locale: "en"} 53 | 54 | conn = conn(:get, "/hello") 55 | 56 | assert_raise RuntimeError, ~r"locale not found in path /hello", fn -> 57 | PutLocaleFromPath.call(conn, options) 58 | end 59 | 60 | conn = conn(:get, "/nl/hallo") 61 | 62 | assert_raise RuntimeError, ~r"locale not found in path /nl/hallo", fn -> 63 | PutLocaleFromPath.call(conn, options) 64 | end 65 | 66 | options = PutLocaleFromPath.init(allowed_locales: ["en", "fr"], default_locale: "en") 67 | 68 | conn = conn(:get, "/hello") 69 | conn = PutLocaleFromPath.call(conn, options) 70 | 71 | assert conn.assigns == %{locale: "en"} 72 | 73 | conn = conn(:get, "/fr/bonjour") 74 | conn = PutLocaleFromPath.call(conn, options) 75 | 76 | assert conn.assigns == %{locale: "fr"} 77 | 78 | conn = conn(:get, "/nl/hallo") 79 | conn = PutLocaleFromPath.call(conn, options) 80 | 81 | assert conn.assigns == %{locale: "en"} 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/plugs/put_locale_from_conn.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Plug) do 2 | defmodule I18nHelpers.Plugs.PutLocaleFromConn do 3 | @moduledoc false 4 | 5 | import Plug.Conn 6 | 7 | @spec init(keyword) :: keyword 8 | def init(options) do 9 | allowed_locales = 10 | Keyword.get(options, :allowed_locales) 11 | |> maybe_to_string() 12 | 13 | default_locale = 14 | Keyword.get(options, :default_locale) 15 | |> maybe_to_string() 16 | 17 | cond do 18 | allowed_locales == nil -> 19 | options 20 | 21 | default_locale == nil -> 22 | options 23 | 24 | true -> 25 | Enum.member?(allowed_locales, default_locale) || 26 | raise ArgumentError, "`default_locale` is not included in `allowed_locales` option" 27 | 28 | options 29 | end 30 | end 31 | 32 | @spec get_allowed_locale_or_default(String.t() | nil, list | nil, String.t() | nil) :: 33 | String.t() | nil 34 | defp get_allowed_locale_or_default(nil, _allowed_locales, default_locale), do: default_locale 35 | 36 | defp get_allowed_locale_or_default(locale, nil, default_locale) do 37 | cond do 38 | byte_size(locale) == 2 -> locale 39 | String.match?(locale, ~r/[\w]{2}[-_][\w]{2}/) -> locale 40 | true -> default_locale 41 | end 42 | end 43 | 44 | defp get_allowed_locale_or_default(locale, allowed_locales, default_locale) do 45 | cond do 46 | Enum.member?(allowed_locales, locale) -> locale 47 | true -> default_locale 48 | end 49 | end 50 | 51 | @spec call(Plug.Conn.t(), keyword) :: Plug.Conn.t() 52 | def call(conn, options) do 53 | find_locale = Keyword.fetch!(options, :find_locale) 54 | handle_missing_locale = Keyword.fetch!(options, :handle_missing_locale) 55 | allowed_locales = Keyword.get(options, :allowed_locales) |> maybe_to_string() 56 | default_locale = Keyword.get(options, :default_locale) |> maybe_to_string() 57 | backend = Keyword.get(options, :backend) 58 | 59 | locale = 60 | get_allowed_locale_or_default(find_locale.(conn), allowed_locales, default_locale) || 61 | handle_missing_locale.(conn) 62 | 63 | if backend, 64 | do: Gettext.put_locale(backend, locale), 65 | else: Gettext.put_locale(locale) 66 | 67 | assign(conn, :locale, locale) 68 | end 69 | 70 | defp maybe_to_string(nil), do: nil 71 | 72 | defp maybe_to_string(locale_list) when is_list(locale_list), 73 | do: Enum.map(locale_list, &to_string/1) 74 | 75 | defp maybe_to_string(locale), do: to_string(locale) 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /test/plugs/put_locale_from_subdomain_test.exs: -------------------------------------------------------------------------------- 1 | defmodule I18nHelpers.Plugs.PutLocaleFromSubdomainTest do 2 | use ExUnit.Case, async: true 3 | 4 | use Plug.Test 5 | 6 | alias I18nHelpers.Plugs.PutLocaleFromSubdomain 7 | 8 | doctest PutLocaleFromSubdomain 9 | 10 | test "init PutLocaleFromSubdomain plug" do 11 | assert_raise ArgumentError, 12 | ~r"`default_locale` is not included in `allowed_locales` option", 13 | fn -> 14 | PutLocaleFromSubdomain.init( 15 | allowed_locales: ["fr", "nl"], 16 | default_locale: "en" 17 | ) 18 | end 19 | end 20 | 21 | test "PutLocaleFromSubdomain plug without options" do 22 | options = PutLocaleFromSubdomain.init([]) 23 | 24 | conn = conn(:get, "https://example.com/hello") 25 | 26 | assert_raise RuntimeError, ~r"locale not found in host example.com", fn -> 27 | PutLocaleFromSubdomain.call(conn, options) 28 | end 29 | 30 | conn = conn(:get, "https://fr.example.com/hello") 31 | conn = PutLocaleFromSubdomain.call(conn, options) 32 | 33 | assert conn.assigns == %{locale: "fr"} 34 | end 35 | 36 | test "PutLocaleFromSubdomain plug options" do 37 | options = PutLocaleFromSubdomain.init(default_locale: :en) 38 | 39 | conn = conn(:get, "https://example.com/hello") 40 | conn = PutLocaleFromSubdomain.call(conn, options) 41 | 42 | assert conn.assigns == %{locale: "en"} 43 | 44 | options = PutLocaleFromSubdomain.init(allowed_locales: ["en", "fr"]) 45 | 46 | conn = conn(:get, "https://en.example.com/hello") 47 | conn = PutLocaleFromSubdomain.call(conn, options) 48 | 49 | assert conn.assigns == %{locale: "en"} 50 | 51 | conn = conn(:get, "https://example.com/hello") 52 | 53 | assert_raise RuntimeError, ~r"locale not found in host example.com", fn -> 54 | PutLocaleFromSubdomain.call(conn, options) 55 | end 56 | 57 | conn = conn(:get, "https://nl.example.com/hallo") 58 | 59 | assert_raise RuntimeError, ~r"locale not found in host nl.example.com", fn -> 60 | PutLocaleFromSubdomain.call(conn, options) 61 | end 62 | 63 | options = PutLocaleFromSubdomain.init(allowed_locales: ["en", :fr], default_locale: "en") 64 | 65 | conn = conn(:get, "https://example.com/hello") 66 | conn = PutLocaleFromSubdomain.call(conn, options) 67 | 68 | assert conn.assigns == %{locale: "en"} 69 | 70 | conn = conn(:get, "https://fr.example.com/bonjour") 71 | conn = PutLocaleFromSubdomain.call(conn, options) 72 | 73 | assert conn.assigns == %{locale: "fr"} 74 | 75 | conn = conn(:get, "https://nl.example.com/hallo") 76 | conn = PutLocaleFromSubdomain.call(conn, options) 77 | 78 | assert conn.assigns == %{locale: "en"} 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /test/plugs/put_locale_from_domain_test.exs: -------------------------------------------------------------------------------- 1 | defmodule I18nHelpers.Plugs.PutLocaleFromDomainTest do 2 | use ExUnit.Case, async: true 3 | 4 | use Plug.Test 5 | 6 | alias I18nHelpers.Plugs.PutLocaleFromDomain 7 | 8 | doctest PutLocaleFromDomain 9 | 10 | test "init PutLocaleFromDomain plug" do 11 | assert_raise ArgumentError, 12 | ~r"must supply `domains_locales_map` option", 13 | fn -> 14 | PutLocaleFromDomain.init([]) 15 | end 16 | 17 | assert_raise ArgumentError, 18 | ~r"`default_locale` is not included in `allowed_locales` option", 19 | fn -> 20 | PutLocaleFromDomain.init( 21 | domains_locales_map: %{ 22 | "english.example" => "en", 23 | "nederlands.example" => "nl" 24 | }, 25 | allowed_locales: ["nl"], 26 | default_locale: "en" 27 | ) 28 | end 29 | end 30 | 31 | test "PutLocaleFromDomain plug with only domains_locales_map option" do 32 | options = 33 | PutLocaleFromDomain.init( 34 | domains_locales_map: %{ 35 | "english.example" => "en", 36 | "nederlands.example" => :nl 37 | } 38 | ) 39 | 40 | conn = conn(:get, "https://example.com/hello") 41 | 42 | assert_raise RuntimeError, ~r"locale not found in host example.com", fn -> 43 | PutLocaleFromDomain.call(conn, options) 44 | end 45 | 46 | conn = conn(:get, "https://english.example/hello") 47 | conn = PutLocaleFromDomain.call(conn, options) 48 | 49 | assert conn.assigns == %{locale: "en"} 50 | 51 | conn = conn(:get, "https://nederlands.example/hallo") 52 | conn = PutLocaleFromDomain.call(conn, options) 53 | 54 | assert conn.assigns == %{locale: "nl"} 55 | 56 | conn = conn(:get, "https://foo.nederlands.example/hallo") 57 | conn = PutLocaleFromDomain.call(conn, options) 58 | 59 | assert conn.assigns == %{locale: "nl"} 60 | end 61 | 62 | test "PutLocaleFromDomain plug options" do 63 | base_options = [ 64 | domains_locales_map: %{ 65 | "english.example" => "en", 66 | "nederlands.example" => "nl" 67 | } 68 | ] 69 | 70 | options = PutLocaleFromDomain.init(Keyword.put(base_options, :default_locale, "en")) 71 | 72 | conn = conn(:get, "https://example.com/hello") 73 | conn = PutLocaleFromDomain.call(conn, options) 74 | 75 | assert conn.assigns == %{locale: "en"} 76 | 77 | options = PutLocaleFromDomain.init(Keyword.put(base_options, :allowed_locales, ["en", "nl"])) 78 | 79 | conn = conn(:get, "https://english.example/hello") 80 | conn = PutLocaleFromDomain.call(conn, options) 81 | 82 | assert conn.assigns == %{locale: "en"} 83 | 84 | conn = conn(:get, "https://example.com/hello") 85 | 86 | assert_raise RuntimeError, ~r"locale not found in host example.com", fn -> 87 | PutLocaleFromDomain.call(conn, options) 88 | end 89 | 90 | options = 91 | Keyword.put(base_options, :allowed_locales, ["en", "nl"]) 92 | |> Keyword.put(:default_locale, "en") 93 | 94 | options = PutLocaleFromDomain.init(options) 95 | 96 | conn = conn(:get, "https://example.com/hello") 97 | conn = PutLocaleFromDomain.call(conn, options) 98 | 99 | assert conn.assigns == %{locale: "en"} 100 | 101 | conn = conn(:get, "https://nederlands.example/hallo") 102 | conn = PutLocaleFromDomain.call(conn, options) 103 | 104 | assert conn.assigns == %{locale: "nl"} 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 3 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 4 | "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 6 | "ecto": {:hex, :ecto, "3.12.2", "bae2094f038e9664ce5f089e5f3b6132a535d8b018bd280a485c2f33df5c0ce1", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "492e67c70f3a71c6afe80d946d3ced52ecc57c53c9829791bfff1830ff5a1f0c"}, 7 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 8 | "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, 9 | "expo": {:hex, :expo, "1.0.1", "f9e2f984f5b8d195815d52d0ba264798c12c8d2f2606f76fa4c60e8ebe39474d", [:mix], [], "hexpm", "f250b33274e3e56513644858c116f255d35c767c2b8e96a512fe7839ef9306a1"}, 10 | "gettext": {:hex, :gettext, "0.26.1", "38e14ea5dcf962d1fc9f361b63ea07c0ce715a8ef1f9e82d3dfb8e67e0416715", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "01ce56f188b9dc28780a52783d6529ad2bc7124f9744e571e1ee4ea88bf08734"}, 11 | "inch_ex": {:hex, :inch_ex, "2.0.0", "24268a9284a1751f2ceda569cd978e1fa394c977c45c331bb52a405de544f4de", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "96d0ec5ecac8cf63142d02f16b7ab7152cf0f0f1a185a80161b758383c9399a8"}, 12 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 13 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 14 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 15 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 16 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, 17 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 18 | "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, 19 | "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, 20 | "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, 21 | "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, 22 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 23 | } 24 | -------------------------------------------------------------------------------- /lib/ecto/translatable_fields.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Ecto) do 2 | defmodule I18nHelpers.Ecto.TranslatableFields do 3 | @moduledoc ~S""" 4 | Provides macros for defining translatable fields and associations. 5 | 6 | This module's purpose is to provide the `I18nHelpers.Ecto.Translator` module 7 | with a way to access the list of fields and associations from the Ecto Schema 8 | that needs to be translated, and with virtual fields allowing to store the 9 | translations for the current locale. 10 | 11 | `__using__\1` this module provides the caller module with two functions: 12 | `get_translatable_fields\0` and `get_translatable_assocs\0` listing all the 13 | translatable fields and the translatable associations respectively. 14 | 15 | Fields that are to be translated are expected to hold maps 16 | 17 | field :title, :map 18 | 19 | where each key represents a locale and each value contains the text for 20 | that locale. Below is an example of such map: 21 | 22 | %{ 23 | "en" => "My Favorite Books", 24 | "fr" => "Mes Livres Préférés", 25 | "nl" => "Mijn Lievelingsboeken", 26 | "en-GB" => "My Favourite Books" 27 | } 28 | 29 | Each of those fields must come with a virtual field which is used to 30 | hold the translation for the current locale. 31 | 32 | field :title, :map 33 | field :translated_title, :string, virtual: true 34 | 35 | Such a translatable field must be included in the translatable fields list: 36 | 37 | def get_translatable_fields, do: [:title] 38 | 39 | This module provides the macro `translatable_field\1` which allows to execute 40 | those three steps above (add the field as `:map`, add the virtual field and 41 | add the field to the translatable fields list) in one line: 42 | 43 | translatable_field :title 44 | 45 | Macros marking associations as translatable are also provided: 46 | 47 | * translatable_belongs_to\3 48 | * translatable_has_many\3 49 | * translatable_has_one\3 50 | * translatable_many_to_many\3 51 | 52 | The macros above add the given association field name to the translatable 53 | associations list, which is accessible with `get_translatable_assocs\0`. 54 | """ 55 | 56 | alias I18nHelpers.Ecto.TranslatableType 57 | 58 | @callback get_translatable_fields() :: [atom] 59 | @callback get_translatable_assocs() :: [atom] 60 | 61 | defmacro __using__(_args) do 62 | this_module = __MODULE__ 63 | 64 | quote do 65 | @behaviour unquote(this_module) 66 | 67 | import unquote(this_module), 68 | only: [ 69 | translatable_field: 1, 70 | translatable_belongs_to: 2, 71 | translatable_belongs_to: 3, 72 | translatable_has_many: 2, 73 | translatable_has_many: 3, 74 | translatable_has_one: 2, 75 | translatable_has_one: 3, 76 | translatable_many_to_many: 2, 77 | translatable_many_to_many: 3 78 | ] 79 | 80 | Module.register_attribute(__MODULE__, :translatable_fields, accumulate: true) 81 | Module.register_attribute(__MODULE__, :translatable_assocs, accumulate: true) 82 | 83 | @before_compile unquote(this_module) 84 | end 85 | end 86 | 87 | defmacro __before_compile__(_env) do 88 | quote do 89 | def get_translatable_fields(), do: @translatable_fields 90 | def get_translatable_assocs(), do: @translatable_assocs 91 | end 92 | end 93 | 94 | @doc ~S""" 95 | Defines a translatable field on the schema. 96 | 97 | This macro will generate two fields: 98 | 99 | * a field with the given name and type `:map` and 100 | * a virtual field with the given name prepended by `"translated_"` and type `:string`. 101 | 102 | For example 103 | 104 | translatable_field :title 105 | 106 | will generate 107 | 108 | field :title, :map 109 | field :translated_title, :string, virtual: true 110 | 111 | The macro will add the given field name into the translatable fields list. 112 | """ 113 | defmacro translatable_field(field_name) do 114 | quote do 115 | field(unquote(field_name), TranslatableType) 116 | 117 | field(String.to_atom("translated_" <> Atom.to_string(unquote(field_name))), :string, 118 | virtual: true 119 | ) 120 | 121 | Module.put_attribute(__MODULE__, :translatable_fields, unquote(field_name)) 122 | end 123 | end 124 | 125 | @doc ~S""" 126 | Defines a translatable `belongs_to` association. 127 | 128 | The macro will add the given field name into the translatable associations list. 129 | """ 130 | defmacro translatable_belongs_to(field_name, module_name, opts \\ []) do 131 | quote do 132 | belongs_to(unquote(field_name), unquote(module_name), unquote(opts)) 133 | 134 | Module.put_attribute(__MODULE__, :translatable_assocs, unquote(field_name)) 135 | end 136 | end 137 | 138 | @doc ~S""" 139 | Defines a translatable `has_many` association. 140 | 141 | The macro will add the given field name into the translatable associations list. 142 | """ 143 | defmacro translatable_has_many(field_name, module_name, opts \\ []) do 144 | quote do 145 | has_many(unquote(field_name), unquote(module_name), unquote(opts)) 146 | 147 | Module.put_attribute(__MODULE__, :translatable_assocs, unquote(field_name)) 148 | end 149 | end 150 | 151 | @doc ~S""" 152 | Defines a translatable `has_one` association. 153 | 154 | The macro will add the given field name into the translatable associations list. 155 | """ 156 | defmacro translatable_has_one(field_name, module_name, opts \\ []) do 157 | quote do 158 | has_one(unquote(field_name), unquote(module_name), unquote(opts)) 159 | 160 | Module.put_attribute(__MODULE__, :translatable_assocs, unquote(field_name)) 161 | end 162 | end 163 | 164 | @doc ~S""" 165 | Defines a translatable `many_to_many` association. 166 | 167 | The macro will add the given field name into the translatable associations list. 168 | """ 169 | defmacro translatable_many_to_many(field_name, module_name, opts \\ []) do 170 | quote do 171 | many_to_many(unquote(field_name), unquote(module_name), unquote(opts)) 172 | 173 | Module.put_attribute(__MODULE__, :translatable_assocs, unquote(field_name)) 174 | end 175 | end 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /test/html/input_helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule I18nHelpers.HTML.InputHelpersTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias I18nHelpers.HTML.InputHelpers 5 | alias I18nHelpers.Ecto.TranslatableFields 6 | 7 | import Phoenix.HTML 8 | import PhoenixHTMLHelpers.Tag 9 | import PhoenixHTMLHelpers.Form 10 | 11 | doctest InputHelpers 12 | 13 | defmodule Post do 14 | use Ecto.Schema 15 | use TranslatableFields 16 | 17 | schema "posts" do 18 | translatable_field :title 19 | translatable_field :body 20 | end 21 | end 22 | 23 | defmodule MyGettext do 24 | use Gettext, otp_app: :i18n_helpers 25 | end 26 | 27 | defp conn do 28 | Plug.Test.conn(:get, "/") 29 | end 30 | 31 | test "generate textarea" do 32 | form = 33 | safe_to_string( 34 | form_for(conn(), "/", fn f -> 35 | InputHelpers.translated_textarea(f, :title, :fr) 36 | end) 37 | ) 38 | 39 | assert form =~ ~s() 40 | end 41 | 42 | test "generate textarea with attribute" do 43 | form = 44 | safe_to_string( 45 | form_for(conn(), "/", fn f -> 46 | InputHelpers.translated_textarea(f, :title, :fr, class: "test") 47 | end) 48 | ) 49 | 50 | assert form =~ ~s() 51 | end 52 | 53 | test "generate text input" do 54 | form = 55 | safe_to_string( 56 | form_for(conn(), "/", fn f -> 57 | InputHelpers.translated_text_input(f, :title, :fr) 58 | end) 59 | ) 60 | 61 | assert form =~ ~s() 62 | 63 | refute form =~ ~s() 64 | end 65 | 66 | test "generate input of type number" do 67 | form = 68 | safe_to_string( 69 | form_for(conn(), "/", fn f -> 70 | InputHelpers.translated_text_input(f, :title, :fr, type: "number", class: "test") 71 | end) 72 | ) 73 | 74 | assert form =~ ~s() 75 | 76 | refute form =~ ~s() 77 | end 78 | 79 | test "generate element" do 80 | form = 81 | safe_to_string( 82 | form_for(conn(), "/", fn f -> 83 | InputHelpers.translated_element(f, :title, :fr) 84 | end) 85 | ) 86 | 87 | assert form =~ ~s(
) 88 | 89 | form = 90 | safe_to_string( 91 | form_for(conn(), "/", fn f -> 92 | InputHelpers.translated_element(f, :title, :fr, tag: :span) 93 | end) 94 | ) 95 | 96 | assert form =~ ~s() 97 | end 98 | 99 | test "generate multiple text inputs" do 100 | form = 101 | safe_to_string( 102 | form_for(conn(), "/", fn f -> 103 | InputHelpers.translated_text_inputs(f, :title, [:en, :fr]) 104 | end) 105 | ) 106 | 107 | assert form =~ 108 | ~s() 109 | end 110 | 111 | test "generate multiple textareas" do 112 | form = 113 | safe_to_string( 114 | form_for(conn(), "/", fn f -> 115 | InputHelpers.translated_textareas(f, :title, [:en, :fr]) 116 | end) 117 | ) 118 | 119 | assert form =~ 120 | ~s() 121 | end 122 | 123 | test "generate multiple elements" do 124 | form = 125 | safe_to_string( 126 | form_for(conn(), "/", fn f -> 127 | InputHelpers.translated_elements(f, :title, [:en, :fr], tag: :span) 128 | end) 129 | ) 130 | 131 | assert form =~ 132 | ~s() 133 | end 134 | 135 | test "generate multiple textareas with Gettext backend" do 136 | form = 137 | safe_to_string( 138 | form_for(conn(), "/", fn f -> 139 | InputHelpers.translated_textareas( 140 | f, 141 | :title, 142 | I18nHelpers.HTML.InputHelpersTest.MyGettext 143 | ) 144 | end) 145 | ) 146 | 147 | assert form =~ 148 | ~s() 149 | end 150 | 151 | @tag :wip 152 | test "generate multiple textareas with custom labels" do 153 | form = 154 | safe_to_string( 155 | form_for(conn(), "/", fn f -> 156 | InputHelpers.translated_textareas(f, :title, [:en, :fr], 157 | labels: fn locale -> raw("#{locale}") end 158 | ) 159 | end) 160 | ) 161 | 162 | assert form =~ 163 | ~s() 164 | end 165 | 166 | @tag :wip 167 | test "generate multiple text inputs with custom labels" do 168 | form = 169 | safe_to_string( 170 | form_for(conn(), "/", fn f -> 171 | InputHelpers.translated_text_inputs(f, :title, [:en, :fr], 172 | labels: fn locale -> {content_tag(:i, locale), class: "test"} end 173 | ) 174 | end) 175 | ) 176 | 177 | assert form =~ 178 | ~s() 179 | end 180 | 181 | test "generate multiple text inputs with wrapping container" do 182 | form = 183 | safe_to_string( 184 | form_for(conn(), "/", fn f -> 185 | InputHelpers.translated_text_inputs(f, :title, [:en, :fr], 186 | labels: fn locale -> content_tag(:i, locale) end, 187 | wrappers: fn _locale -> {:div, class: "test"} end 188 | ) 189 | end) 190 | ) 191 | 192 | assert form =~ 193 | ~s() 194 | end 195 | end 196 | -------------------------------------------------------------------------------- /lib/ecto/translator.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Ecto) do 2 | defmodule I18nHelpers.Ecto.Translator do 3 | @doc ~S""" 4 | Translates an Ecto struct, a list of Ecto structs or a map containing translations. 5 | 6 | Translating an Ecto struct for a given locale consists of the following steps: 7 | 8 | 1. Get the list of the fields that need to be translated from the Schema. 9 | The Schema must contain a `get_translatable_fields\0` function returning 10 | a list of those fields. 11 | 12 | 2. Get the text for the given locale and store it into a virtual field. 13 | The Schema must provide, for each translatable field, a corresponding 14 | virtual field in order to store the translation. 15 | 16 | 3. Get the list of the associations that also need to be translated from 17 | the Schema. The Schema must contain a `get_translatable_assocs\0` function 18 | returning a list of those associations. 19 | 20 | 4. Repeat step 1. for each associated Ecto struct. 21 | """ 22 | @spec translate(list | struct | map, list | String.t() | atom, keyword) :: 23 | list | struct | String.t() 24 | def translate(data_structure, locale \\ Gettext.get_locale(), opts \\ []) 25 | 26 | def translate([], _locale, _opts), do: [] 27 | 28 | def translate([head | tail], locale, opts) do 29 | [ 30 | translate(head, locale, opts) 31 | | translate(tail, locale, opts) 32 | ] 33 | end 34 | 35 | def translate(%{__struct__: _struct_name} = struct, locale, opts) do 36 | translate_struct(struct, locale, opts) 37 | end 38 | 39 | def translate(%{} = map, locale, opts) do 40 | translate_map(map, locale, opts) 41 | end 42 | 43 | def translate(nil, locale, opts) do 44 | translate_map(%{}, locale, opts) 45 | end 46 | 47 | defp translate_struct(%{__struct__: _struct_name} = entity, locale, opts) do 48 | fields_to_translate = entity.__struct__.get_translatable_fields() 49 | assocs_to_translate = entity.__struct__.get_translatable_assocs() 50 | 51 | entity = 52 | Enum.reduce(fields_to_translate, entity, fn field, updated_entity -> 53 | virtual_translated_field = String.to_atom("translated_" <> Atom.to_string(field)) 54 | 55 | %{^field => translations} = entity 56 | 57 | handle_missing_translation = fn translations_map, locale -> 58 | Keyword.get(opts, :handle_missing_field_translation, fn _, _, _ -> true end) 59 | |> apply([field, translations_map, locale]) 60 | 61 | Keyword.get(opts, :handle_missing_translation, fn _, _ -> true end) 62 | |> apply([translations_map, locale]) 63 | end 64 | 65 | opts = Keyword.put(opts, :handle_missing_translation, handle_missing_translation) 66 | 67 | struct(updated_entity, [ 68 | {virtual_translated_field, translate(translations, locale, opts)} 69 | ]) 70 | end) 71 | 72 | entity = 73 | Enum.reduce(assocs_to_translate, entity, fn field, updated_entity -> 74 | %{^field => assoc} = entity 75 | 76 | case Ecto.assoc_loaded?(assoc) do 77 | true -> 78 | struct(updated_entity, [{field, translate(assoc, locale, opts)}]) 79 | 80 | _ -> 81 | updated_entity 82 | end 83 | end) 84 | 85 | entity 86 | end 87 | 88 | defp translate_map(%{} = translations_map, [] = _allowed_locales, opts) do 89 | fallback_locale = 90 | Keyword.get(opts, :fallback_locale, Gettext.get_locale()) 91 | |> to_string() 92 | 93 | handle_missing_translation = 94 | Keyword.get(opts, :handle_missing_translation, fn _, _ -> true end) 95 | 96 | cond do 97 | has_translation?(translations_map, fallback_locale) -> 98 | translations_map[fallback_locale] 99 | 100 | true -> 101 | handle_missing_translation.(translations_map, fallback_locale) 102 | "" 103 | end 104 | end 105 | 106 | defp translate_map(%{} = translations_map, [locale | rest] = _allowed_locales, opts) do 107 | locale = to_string(locale) 108 | 109 | handle_missing_translation = 110 | Keyword.get(opts, :handle_missing_translation, fn _, _ -> true end) 111 | 112 | cond do 113 | has_translation?(translations_map, locale) -> 114 | translations_map[locale] 115 | 116 | true -> 117 | handle_missing_translation.(translations_map, locale) 118 | translate_map(translations_map, rest, opts) 119 | end 120 | end 121 | 122 | defp translate_map(%{} = translations_map, locale, opts) do 123 | locale = to_string(locale) 124 | 125 | fallback_locale = 126 | Keyword.get(opts, :fallback_locale, Gettext.get_locale()) 127 | |> to_string() 128 | 129 | handle_missing_translation = 130 | Keyword.get(opts, :handle_missing_translation, fn _, _ -> true end) 131 | 132 | cond do 133 | has_translation?(translations_map, locale) -> 134 | translations_map[locale] 135 | 136 | has_translation?(translations_map, fallback_locale) -> 137 | translation = translations_map[fallback_locale] 138 | handle_missing_translation.(translations_map, locale) 139 | translation 140 | 141 | true -> 142 | handle_missing_translation.(translations_map, locale) 143 | "" 144 | end 145 | end 146 | 147 | @doc ~S""" 148 | Same as `translate/3` but raises an error if a translation is missing. 149 | """ 150 | @spec translate!(list | struct | map, list | String.t() | atom, keyword) :: 151 | list | struct | String.t() 152 | def translate!(data_structure, locale \\ Gettext.get_locale(), opts \\ []) do 153 | handle_missing_field_translation = fn field, translations_map, locale -> 154 | Keyword.get(opts, :handle_missing_field_translation, fn _, _, _ -> true end) 155 | |> apply([field, translations_map, locale]) 156 | 157 | raise "translation of field #{inspect(field)} for locale \"#{locale}\" not found in map #{inspect(translations_map)}" 158 | end 159 | 160 | handle_missing_translation = fn translations_map, locale -> 161 | Keyword.get(opts, :handle_missing_translation, fn _, _ -> true end) 162 | |> apply([translations_map, locale]) 163 | 164 | raise "translation for locale \"#{locale}\" not found in map #{inspect(translations_map)}" 165 | end 166 | 167 | opts = 168 | opts 169 | |> Keyword.put(:handle_missing_field_translation, handle_missing_field_translation) 170 | |> Keyword.put(:handle_missing_translation, handle_missing_translation) 171 | 172 | translate(data_structure, locale, opts) 173 | end 174 | 175 | defp has_translation?(translations_map, locale), 176 | do: Map.has_key?(translations_map, locale) && String.trim(locale) != "" 177 | 178 | # @doc ~S""" 179 | # Returns a closure allowing to memorize the given options for `translate\3`. 180 | # """ 181 | def set_opts(opts) do 182 | fn data_structure, overriding_opts -> 183 | opts = Keyword.merge(opts, overriding_opts) 184 | locale = Keyword.get(opts, :locale, Gettext.get_locale()) 185 | 186 | translate(data_structure, locale, opts) 187 | end 188 | end 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /lib/html/input_helpers.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Phoenix.HTML) do 2 | defmodule I18nHelpers.HTML.InputHelpers do 3 | @moduledoc ~S""" 4 | Provides view helpers to render HTML input fields for text that must be 5 | provided in multiple languages. The multilingual texts passed to the 6 | form (usually in a changeset) are expected to be maps where each key 7 | represents a locale and each value contains the text for that locale. 8 | For example: 9 | 10 | %{ 11 | "en" => "hello world", 12 | "fr" => "bonjour monde", 13 | "nl" => "hallo wereld" 14 | } 15 | """ 16 | 17 | alias Phoenix.HTML.Form 18 | 19 | @doc ~S""" 20 | Renders a text input HTML element filled with the translated value for the 21 | given locale. 22 | 23 | Additional HTML attributes can be provided through opts argument. 24 | """ 25 | def translated_text_input(form, field, locale, opts \\ []) do 26 | opts = Keyword.put_new(opts, :name, translated_input_name(form, field, locale)) 27 | 28 | translated_field(form, field, {:input, [type: "text"]}, locale, opts) 29 | end 30 | 31 | @doc ~S""" 32 | Renders a textarea input HTML element filled with the translated value for the 33 | given locale. 34 | 35 | Additional HTML attributes can be provided through opts argument. 36 | """ 37 | def translated_textarea(form, field, locale, opts \\ []) do 38 | opts = Keyword.put_new(opts, :name, translated_input_name(form, field, locale)) 39 | 40 | translated_field(form, field, {:textarea, []}, locale, opts) 41 | end 42 | 43 | @doc ~S""" 44 | Renders a custom input HTML element filled with the translated value for the 45 | given locale. The default element is `div` and may be changed through the `tag` 46 | option. 47 | 48 | Additional HTML attributes can be provided through opts argument. 49 | """ 50 | def translated_element(form, field, locale, opts \\ []) do 51 | {tag, opts} = Keyword.pop(opts, :tag, :div) 52 | 53 | translated_field(form, field, {tag, []}, locale, opts) 54 | end 55 | 56 | defp translated_field(form, field, {tag, attrs}, locale, opts) do 57 | locale = to_string(locale) 58 | 59 | translations = Form.input_value(form, field) || %{} 60 | translation = Map.get(translations, locale, "") 61 | 62 | attrs = 63 | [id: translated_input_id(form, field, locale)] 64 | |> Keyword.merge(attrs) 65 | |> Keyword.merge(opts) 66 | 67 | tag(tag, translation, attrs) 68 | end 69 | 70 | @doc ~S""" 71 | Renders multiple text input HTML elements for the given locales (one for each locale). 72 | 73 | ## Options 74 | 75 | The options allow providing additional HTML attributes, as well as: 76 | 77 | * `:labels` - an anonymous function returning the label for each generated input; 78 | the locale is given as argument 79 | * `:wrappers` - an anonymous function returning a custom wrapper for each generated input; 80 | the locale is given as argument 81 | 82 | ## Example 83 | 84 | ``` 85 | translated_text_inputs(f, :title, [:en, :fr], 86 | labels: fn locale -> content_tag(:i, locale) end, 87 | wrappers: fn _locale -> {:div, class: "translated-input-wrapper"} end 88 | ) 89 | ``` 90 | """ 91 | def translated_text_inputs(form, field, locales_or_gettext_backend, opts \\ []) 92 | 93 | def translated_text_inputs(form, field, gettext_backend, opts) 94 | when is_atom(gettext_backend) do 95 | translated_text_inputs(form, field, Gettext.known_locales(gettext_backend), opts) 96 | end 97 | 98 | def translated_text_inputs(form, field, locales, opts) do 99 | translated_fields(&translated_text_input/4, form, field, locales, opts) 100 | end 101 | 102 | @doc ~S""" 103 | Renders multiple textarea HTML elements for the given locales (one for each locale). 104 | 105 | For options, see `translated_text_inputs/4` 106 | """ 107 | def translated_textareas(form, field, locales_or_gettext_backend, opts \\ []) 108 | 109 | def translated_textareas(form, field, gettext_backend, opts) when is_atom(gettext_backend) do 110 | translated_textareas(form, field, Gettext.known_locales(gettext_backend), opts) 111 | end 112 | 113 | def translated_textareas(form, field, locales, opts) do 114 | translated_fields(&translated_textarea/4, form, field, locales, opts) 115 | end 116 | 117 | @doc ~S""" 118 | Renders multiple custom HTML elements for the given locales (one for each locale). 119 | The default element is `div` and may be changed through the `tag` option. 120 | 121 | For options, see `translated_text_inputs/4` 122 | """ 123 | def translated_elements(form, field, locales_or_gettext_backend, opts \\ []) 124 | 125 | def translated_elements(form, field, gettext_backend, opts) when is_atom(gettext_backend) do 126 | translated_elements(form, field, Gettext.known_locales(gettext_backend), opts) 127 | end 128 | 129 | def translated_elements(form, field, locales, opts) do 130 | translated_fields(&translated_element/4, form, field, locales, opts) 131 | end 132 | 133 | defp translated_fields(fun, form, field, locales, opts) do 134 | {get_label_data, opts} = Keyword.pop(opts, :labels, fn locale -> locale end) 135 | {get_wrapper_data, opts} = Keyword.pop(opts, :wrappers, fn _locale -> nil end) 136 | 137 | Enum.map(locales, fn locale -> 138 | locale = to_string(locale) 139 | 140 | wrap(get_wrapper_data.(locale), fn -> 141 | [ 142 | render_label(form, translated_label_for(field, locale), get_label_data.(locale)), 143 | fun.(form, field, locale, opts) 144 | ] 145 | end) 146 | end) 147 | end 148 | 149 | defp wrap(nil, render_content), do: render_content.() 150 | 151 | defp wrap({tag, opts}, render_content) do 152 | PhoenixHTMLHelpers.Tag.content_tag tag, opts do 153 | render_content.() 154 | end 155 | end 156 | 157 | defp render_label(form, field, {{:safe, _} = label, opts}), 158 | do: safe_render_label(form, field, label, opts) 159 | 160 | defp render_label(form, field, {:safe, _} = label), 161 | do: safe_render_label(form, field, label, []) 162 | 163 | defp render_label(form, field, {label, opts}), 164 | do: safe_render_label(form, field, label, opts) 165 | 166 | defp render_label(form, field, label), 167 | do: safe_render_label(form, field, label, []) 168 | 169 | defp safe_render_label(form, field, label, opts) do 170 | PhoenixHTMLHelpers.Form.label form, field, opts do 171 | label 172 | end 173 | end 174 | 175 | defp tag(:input = tag, content, attrs) do 176 | attrs = Keyword.put_new(attrs, :value, content) 177 | 178 | PhoenixHTMLHelpers.Tag.tag(tag, attrs) 179 | end 180 | 181 | defp tag(tag, content, attrs) do 182 | PhoenixHTMLHelpers.Tag.content_tag(tag, content, attrs) 183 | end 184 | 185 | defp translated_input_id(form, field, locale) do 186 | "#{Form.input_id(form, field)}_#{locale}" 187 | end 188 | 189 | defp translated_input_name(form, field, locale) do 190 | "#{Form.input_name(form, field)}[#{locale}]" 191 | end 192 | 193 | defp translated_label_for(field, locale) do 194 | "#{field}_#{locale}" 195 | end 196 | end 197 | end 198 | -------------------------------------------------------------------------------- /test/ecto/translator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule I18nHelpers.Ecto.TranslatorTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias I18nHelpers.Ecto.Translator 5 | alias I18nHelpers.Ecto.TranslatableFields 6 | 7 | doctest Translator 8 | 9 | defmodule Category do 10 | use Ecto.Schema 11 | use TranslatableFields 12 | 13 | schema "categories" do 14 | translatable_field :name 15 | translatable_belongs_to :parent_category, Category 16 | translatable_many_to_many :menus, Menu, join_through: "categories_menus" 17 | end 18 | end 19 | 20 | defmodule Post do 21 | use Ecto.Schema 22 | use TranslatableFields 23 | 24 | schema "posts" do 25 | translatable_field :title 26 | translatable_field :body 27 | translatable_has_many :comments, Comment 28 | translatable_belongs_to :category, Category 29 | end 30 | end 31 | 32 | defmodule Comment do 33 | @behaviour I18nHelpers.Ecto.TranslatableFields 34 | 35 | use Ecto.Schema 36 | 37 | schema "comments" do 38 | field(:text, :map) 39 | field(:translated_text, :string, virtual: true) 40 | has_one(:state, State) 41 | end 42 | 43 | def get_translatable_fields, do: [:text] 44 | def get_translatable_assocs, do: [:state] 45 | end 46 | 47 | defmodule State do 48 | use Ecto.Schema 49 | use TranslatableFields 50 | 51 | schema "states" do 52 | translatable_field :name 53 | end 54 | end 55 | 56 | defmodule Menu do 57 | use Ecto.Schema 58 | use TranslatableFields 59 | 60 | schema "menus" do 61 | translatable_field :label 62 | end 63 | end 64 | 65 | defmodule MyTranslator do 66 | def translate(data_structure, locale \\ Gettext.get_locale(), opts \\ []) do 67 | handle_missing_translation = 68 | Keyword.get(opts, :handle_missing_translation, &handle_missing_translation/2) 69 | 70 | opts = Keyword.put(opts, :handle_missing_translation, handle_missing_translation) 71 | 72 | Translator.translate(data_structure, locale, opts) 73 | end 74 | 75 | def handle_missing_translation(translations_map, locale) do 76 | raise "missing translation for locale `#{locale}` in #{inspect(translations_map)}" 77 | end 78 | end 79 | 80 | test "get translation from empty map or nil" do 81 | assert Translator.translate(%{}) == "" 82 | assert Translator.translate(%{}, "fr") == "" 83 | assert Translator.translate(nil) == "" 84 | end 85 | 86 | test "get translation from map, given key is present" do 87 | assert Translator.translate(%{"en" => "hello", "fr" => "bonjour"}) == "hello" 88 | assert Translator.translate(%{"en" => "hello", "fr" => "bonjour"}, "en") == "hello" 89 | assert Translator.translate(%{"en" => "hello", "fr" => "bonjour"}, "fr") == "bonjour" 90 | assert Translator.translate(%{"en" => "hello", "fr" => "bonjour"}, :fr) == "bonjour" 91 | end 92 | 93 | test "get translation from map, list of locales" do 94 | assert Translator.translate(%{"en" => "hello", "fr" => "bonjour"}) == "hello" 95 | assert Translator.translate(%{"en" => "hello", "fr" => "bonjour"}, ["en", "fr"]) == "hello" 96 | assert Translator.translate(%{"en" => "hello", "fr" => "bonjour"}, ["fr", "en"]) == "bonjour" 97 | assert Translator.translate(%{"en" => "hello", "fr" => "bonjour"}, ["it", "fr"]) == "bonjour" 98 | assert Translator.translate(%{"en" => "hello", "fr" => "bonjour"}, [:fr, :en]) == "bonjour" 99 | assert Translator.translate(%{"fr" => "bonjour"}, ["it", "nl"]) == "" 100 | 101 | assert Translator.translate(%{"en" => "hello", "fr" => "bonjour"}, ["it", "nl"], 102 | fallback_locale: "fr" 103 | ) == "bonjour" 104 | end 105 | 106 | test "get translation from map, given key is missing" do 107 | assert Translator.translate(%{"en" => "hello"}, "nl") == "hello" 108 | 109 | Gettext.put_locale("nl") 110 | assert Translator.translate(%{"en" => "hello"}) == "" 111 | end 112 | 113 | test "option: fallback locale" do 114 | assert Translator.translate(%{"fr" => "bonjour"}, "en") == "" 115 | assert Translator.translate(%{"fr" => "bonjour"}, "en", fallback_locale: "fr") == "bonjour" 116 | assert Translator.translate(%{"fr" => "bonjour"}, :en, fallback_locale: :fr) == "bonjour" 117 | end 118 | 119 | test "option: missing translation handler" do 120 | Translator.translate(%{"fr" => "bonjour"}, "en", 121 | handle_missing_translation: fn translations_map, locale -> 122 | assert translations_map == %{"fr" => "bonjour"} 123 | assert locale == "en" 124 | end 125 | ) 126 | end 127 | 128 | test "get translations from list of maps" do 129 | assert Translator.translate([ 130 | %{"en" => "hello", "fr" => "bonjour"}, 131 | %{"en" => "world", "nl" => "wereld"}, 132 | %{"fr" => "toto"} 133 | ]) == [ 134 | "hello", 135 | "world", 136 | "" 137 | ] 138 | 139 | assert Translator.translate( 140 | [ 141 | %{"en" => "hello", "fr" => "bonjour"}, 142 | %{"en" => "world", "nl" => "wereld"}, 143 | %{"fr" => "toto"} 144 | ], 145 | "fr", 146 | fallback_locale: "nl" 147 | ) == [ 148 | "bonjour", 149 | "wereld", 150 | "toto" 151 | ] 152 | end 153 | 154 | test "translate struct" do 155 | post = %Post{ 156 | title: %{"en" => "The title", "fr" => "Le titre"}, 157 | body: %{"en" => "The content", "fr" => "Le contenu"} 158 | } 159 | 160 | assert post.translated_title == nil 161 | 162 | translated_post = Translator.translate(post, "en") 163 | 164 | assert translated_post.translated_title == "The title" 165 | assert translated_post.translated_body == "The content" 166 | end 167 | 168 | test "translate struct with locale list" do 169 | post = %Post{ 170 | title: %{"en" => "The title", "fr" => "Le titre", "it" => "Il titolo"}, 171 | body: %{"en" => "The content", "fr" => "Le contenu"} 172 | } 173 | 174 | assert post.translated_title == nil 175 | 176 | translated_post = Translator.translate(post, ["it", "fr"]) 177 | 178 | assert translated_post.translated_title == "Il titolo" 179 | assert translated_post.translated_body == "Le contenu" 180 | end 181 | 182 | test "translate struct with associations" do 183 | comment = 184 | %Comment{text: %{"en" => "A comment", "fr" => "Un commentaire"}} 185 | |> Map.put(:state, %State{ 186 | name: %{"en" => "Pending validation", "fr" => "En attente de validation"} 187 | }) 188 | 189 | category = 190 | %Category{name: %{"en" => "The category", "fr" => "La catégorie"}} 191 | |> Map.put(:parent_category, %Category{ 192 | name: %{"en" => "The parent category", "fr" => "La catégorie mère"} 193 | }) 194 | |> Map.put(:menus, [ 195 | %Menu{ 196 | label: %{"en" => "A menu", "fr" => "Un menu"} 197 | }, 198 | %Menu{ 199 | label: %{"en" => "Another menu", "fr" => "Un autre menu"} 200 | } 201 | ]) 202 | 203 | post = 204 | %Post{ 205 | title: %{"en" => "The title", "fr" => "Le titre"}, 206 | body: %{"en" => "The content", "fr" => "Le contenu"} 207 | } 208 | |> Map.put(:comments, [comment]) 209 | |> Map.put(:category, category) 210 | 211 | translated_post = Translator.translate(post, :fr) 212 | 213 | assert translated_post.translated_title == "Le titre" 214 | assert translated_post.translated_body == "Le contenu" 215 | assert hd(translated_post.comments).translated_text == "Un commentaire" 216 | assert hd(translated_post.comments).state.translated_name == "En attente de validation" 217 | assert translated_post.category.translated_name == "La catégorie" 218 | assert translated_post.category.parent_category.translated_name == "La catégorie mère" 219 | assert hd(translated_post.category.menus).translated_label == "Un menu" 220 | end 221 | 222 | test "translate with opts" do 223 | translate = Translator.set_opts(fallback_locale: "fr") 224 | 225 | assert translate.(%{"fr" => "bonjour", "nl" => "hallo"}, locale: "en") == "bonjour" 226 | 227 | assert translate.(%{"fr" => "bonjour", "nl" => "hallo"}, locale: "en", fallback_locale: :nl) == 228 | "hallo" 229 | 230 | assert translate.(%{"fr" => "bonjour", "nl" => "hallo"}, []) == "bonjour" 231 | end 232 | 233 | test "translate with custom translator" do 234 | assert_raise RuntimeError, 235 | ~r"missing translation for locale `en` in %{\"fr\" => \"bonjour\"}", 236 | fn -> 237 | MyTranslator.translate(%{"fr" => "bonjour"}, "en") == "" 238 | end 239 | end 240 | 241 | test "cast filters out empty translations" do 242 | params = %{ 243 | "title" => %{"en" => "The title", "fr" => ""}, 244 | "body" => %{"en" => "", "fr" => ""} 245 | } 246 | 247 | changeset = Ecto.Changeset.cast(%Post{}, params, [:title, :body]) 248 | 249 | assert Map.has_key?(changeset.changes, :title) 250 | assert Map.has_key?(changeset.changes.title, "en") 251 | refute Map.has_key?(changeset.changes.title, "fr") 252 | 253 | refute Map.has_key?(changeset.changes, :body) 254 | end 255 | 256 | test "translate struct with empty translations" do 257 | post = %Post{ 258 | title: %{"fr" => "Le titre"}, 259 | body: nil 260 | } 261 | 262 | assert post.translated_title == nil 263 | 264 | translated_post = Translator.translate(post, "en") 265 | 266 | assert translated_post.translated_title == "" 267 | assert translated_post.translated_body == "" 268 | end 269 | 270 | test "translate!" do 271 | post = %Post{ 272 | title: %{"en" => "The title", "fr" => "Le titre"}, 273 | body: %{"fr" => "Le contenu"} 274 | } 275 | 276 | assert_raise RuntimeError, 277 | ~r"translation of field :body for locale \"en\" not found in map %{\"fr\" => \"Le contenu\"}", 278 | fn -> 279 | Translator.translate!(post, "en") 280 | end 281 | 282 | assert_raise RuntimeError, 283 | ~r"translation for locale \"en\" not found in map %{}", 284 | fn -> 285 | Translator.translate!(%{}, "en") 286 | end 287 | 288 | translated_post = Translator.translate!(post, "fr") 289 | 290 | assert translated_post.translated_title == "Le titre" 291 | end 292 | end 293 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | Copyright 2019 Mathieu Decaffmeyer 179 | 180 | Licensed under the Apache License, Version 2.0 (the "License"); 181 | you may not use this file except in compliance with the License. 182 | You may obtain a copy of the License at 183 | 184 | http://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, software 187 | distributed under the License is distributed on an "AS IS" BASIS, 188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 | See the License for the specific language governing permissions and 190 | limitations under the License. 191 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # I18n Helpers 2 | 3 | *I18n Helpers* are a set of tools to help you adding multilingual support to 4 | your Elixir application. 5 | 6 | **1. [Ease the use of translations stored in database](#translate-your-ecto-schema)** 7 | 8 | * Translate your Ecto Schema structs (including all Schema associations, in one call)