{"id":19210901,"url":"https://github.com/mathieuprog/i18n_helpers","last_synced_at":"2025-06-10T08:34:12.400Z","repository":{"id":35130164,"uuid":"210402267","full_name":"mathieuprog/i18n_helpers","owner":"mathieuprog","description":"A set of tools to help you translate your Elixir applications","archived":false,"fork":false,"pushed_at":"2024-09-01T05:05:58.000Z","size":78,"stargazers_count":19,"open_issues_count":0,"forks_count":6,"subscribers_count":3,"default_branch":"master","last_synced_at":"2025-06-05T11:35:44.360Z","etag":null,"topics":["ecto","elixir","elixir-lang","i18n","translation"],"latest_commit_sha":null,"homepage":null,"language":"Elixir","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/mathieuprog.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null},"funding":{"github":"mathieuprog"}},"created_at":"2019-09-23T16:27:27.000Z","updated_at":"2024-09-01T05:05:53.000Z","dependencies_parsed_at":"2024-11-09T13:39:58.561Z","dependency_job_id":null,"html_url":"https://github.com/mathieuprog/i18n_helpers","commit_stats":null,"previous_names":[],"tags_count":17,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mathieuprog%2Fi18n_helpers","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mathieuprog%2Fi18n_helpers/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mathieuprog%2Fi18n_helpers/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mathieuprog%2Fi18n_helpers/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/mathieuprog","download_url":"https://codeload.github.com/mathieuprog/i18n_helpers/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mathieuprog%2Fi18n_helpers/sbom","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":259038348,"owners_count":22796604,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ecto","elixir","elixir-lang","i18n","translation"],"created_at":"2024-11-09T13:39:42.959Z","updated_at":"2025-06-10T08:34:12.376Z","avatar_url":"https://github.com/mathieuprog.png","language":"Elixir","funding_links":["https://github.com/sponsors/mathieuprog"],"categories":[],"sub_categories":[],"readme":"# I18n Helpers\n\n*I18n Helpers* are a set of tools to help you adding multilingual support to\nyour Elixir application.\n\n**1. [Ease the use of translations stored in database](#translate-your-ecto-schema)**\n\n   * Translate your Ecto Schema structs (including all Schema associations, in one call)\u003cbr\u003e\n   ```elixir\n   post =\n     Repo.all(Post)\n     |\u003e Repo.preload(:category)\n     |\u003e Repo.preload(:comments)\n     |\u003e Translator.translate(\"fr\")\n\n   assert post.translated_title == \"Le titre\"\n   assert post.category.translated_name == \"La catégorie\"\n   assert List.first(post.comments).translated_text == \"Un commentaire\"\n   ```\n   * Provide a fallback locale\n   ```elixir\n   Translator.translate(post, \"nl\", fallback_locale: \"en\")\n   ```\n   * Handle missing translations (e.g. get notified)\n   ```elixir\n   Translator.translate(post, \"en\",\n     handle_missing_translation: fn translations_map, locale -\u003e\n       # add here your error handling stuff,\n       # e.g. notify yourself that a translation is missing\n     end)\n   ```\n\n**2. [Render multilingual field inputs in your Phoenix Form](#phoenix-form-helpers)**\n\n   * Render multilanguage inputs to work with Ecto Schema structs that need\n   translations\n   * Render multilanguage inputs in one call with custom labels and wrappers to\n   customize design\n\n   ![Multilingual fields](https://afreshcloud.com/images/github/i18n_helpers-multilingual_text_fields.png \"Multilingual text inputs\")\n\n**3. [Fetch the locale from the URL](#fetch-the-locale-from-the-URL)**\n\n   * Assign the locale to the connection and set the Gettext locale\n   * Fetch the locale from the request path\u003cbr\u003e\n     e.g. `example.com/en/hello`, `example.com/fr/bonjour`, …\n   * Fetch the locale from the subdomain\u003cbr\u003e\n     e.g. `en.example.com/hello`, `fr.example.com/bonjour`, …\n   * Fetch the locale from the domain name\u003cbr\u003e\n     e.g. `my-awesome-website.example/hello`, `mon-super-site.example/bonjour`, …\n   * Implement a custom locale fetcher\n\n## Translate your Ecto Schema\n\nTranslations must be stored in a JSON data type.\n\n\u003e ***Note:** if you prefer to store translations in separate\ndatabase tables, then this library (at least the Ecto-related helpers) is not for you. Note however\nthat, in my opinion, the pros having a JSON field compared to separate translation tables largely\noutweigh the cons; but I will not debate that here and let you Google that yourself to form your\nown opinion.*\n\nEach translatable field is stored in a map, where each key represents a locale and each value\ncontains the text for that locale. Below is an example of such map:\n\n```elixir\n%{\n  \"en\" =\u003e \"My Favorite Books\",\n  \"fr\" =\u003e \"Mes Livres Préférés\",\n  \"nl\" =\u003e \"Mijn Lievelingsboeken\",\n  \"en-GB\" =\u003e \"My Favourite Books\"\n}\n```\n\n### What this helper does not\n\nLet's first clarify something important in order to understand what this library actually helps with\nand what it does not.\n\nYour translatable text field is essentially a map. In your schema, this translates to:\n\n`field :title, :map`\n\n\u003e  **Note:** the `:map` type is actually wrapped by a custom Ecto type in order to clean empty translations\nfrom maps (more information and examples below).\n\nInserting/updating/deleting translations is not handled by this library, as nothing specific has to\nbe done to perform those with `Ecto.Repo` on a `:map` field.\n\nWhat this library helps with, is **extracting the translations from an Ecto struct and associated\nstructs into virtual fields based on a given locale**, fallback to a given fallback locale, and\nhandling missing translations. See examples below.\n\n### Setup your schema\n\n#### with macro\n\n```elixir\ndefmodule MyApp.Post do\n  use Ecto.Schema\n  use I18nHelpers.Ecto.TranslatableFields\n\n  schema \"posts\" do\n    translatable_field :title\n    translatable_field :body\n    translatable_has_many :comments, MyApp.Comment\n    translatable_belongs_to :category, MyApp.Category\n  end\nend\n```\n\n#### without macro\n\n```elixir\ndefmodule MyApp.Post do\n  @behaviour I18nHelpers.Ecto.TranslatableFields\n\n  use Ecto.Schema\n\n  schema \"posts\" do\n    field :title, :map\n    field :translated_title, :string, virtual: true\n\n    field :body, :map\n    field :translated_body, :string, virtual: true\n\n    has_many :comments, MyApp.Comment\n    belongs_to :category, MyApp.Category\n  end\n\n  def get_translatable_fields, do: [:title, :body]\n  def get_translatable_assocs, do: [:comments, :category]\nend\n```\n\nWhen casting (`Ecto.Changeset.cast/4`) translation maps, missing translations are omitted. For example\n\n```elixir\n%{\"en\" =\u003e \"My Favorite Books\", \"fr\" =\u003e \"\"}\n```\n\nbecomes\n\n```elixir\n%{\"en\" =\u003e \"My Favorite Books\"}\n```\n\nIf no translations are present in the map, casting converts the value to `nil`:\n\n```elixir\n%{\"en\" =\u003e \"\", \"fr\" =\u003e \"\"}\n```\n\nbecomes\n\n```elixir\nnil\n```\n\nYou may import `:i18n_helpers`'s formatter configuration by importing\n`i18n_helpers` into your `.formatter.exs` file (this allows for example to keep\n`translatable_field :title` without parentheses when running `mix format`).\n\n```elixir\n[\n  import_deps: [:ecto, :phoenix, :i18n_helpers],\n  #...\n]\n```\n\nThe translatable fields in your migration file should also be of `:map` type:\n\n```elixir\nadd :title, :map, null: false\nadd :body, :map, null: false\n```\n\n### Translate your Ecto struct\n\nYou will typically translate Schema structs after retrieving them from the database:\n\n```elixir\nalias I18nHelpers.Ecto.Translator\nalias MyApp.{Post, Repo}\n\npost =\n  Repo.all(Post)\n  |\u003e Translator.translate(\"fr\")\n\nassert translated_post.translated_title == \"Le titre\"\nassert translated_post.translated_body == \"Le contenu\"\nassert translated_post.category.translated_name == \"La catégorie\"\n```\n\nNote above that all the associated Schema structs have been translated as well.\n\nI prefer to perform translations in the Phoenix controller:\n\n```elixir\nBlog.get_post!(post_id) # suppose Blog is the context managing posts, comments, etc.\n|\u003e Blog.with_comments_assocs()\n|\u003e Blog.with_category_assoc()\n|\u003e Translator.translate(\"fr\")\n```\n\nBelow is an example that more clearly shows the content of the structs and their translations:\n\n```elixir\nalias I18nHelpers.Ecto.Translator\nalias MyApp.{Category, Comment, Post}\n\ncomments = [\n  %Comment{text: %{\"en\" =\u003e \"A comment\", \"fr\" =\u003e \"Un commentaire\"}},\n  %Comment{text: %{\"en\" =\u003e \"Another comment\", \"fr\" =\u003e \"Un autre commentaire\"}}\n]\n\ncategory =\n  %Category{name: %{\"en\" =\u003e \"The category\", \"fr\" =\u003e \"La catégorie\"}}\n\npost =\n  %Post{\n    title: %{\"en\" =\u003e \"The title\", \"fr\" =\u003e \"Le titre\"},\n    body: %{\"en\" =\u003e \"The content\", \"fr\" =\u003e \"Le contenu\"}\n  }\n  |\u003e Map.put(:comments, comments)\n  |\u003e Map.put(:category, category)\n\ntranslated_post = Translator.translate(post, \"fr\")\n\nassert translated_post.translated_title == \"Le titre\"\nassert translated_post.translated_body == \"Le contenu\"\nassert hd(translated_post.comments).translated_text == \"Un commentaire\"\nassert translated_post.category.translated_name == \"La catégorie\"\n```\n\nYou can also translate a single field:\n\n```elixir\ntitle = Translator.translate(post.title, \"fr\") # post.title == %{\"en\" =\u003e \"The title\", \"fr\" =\u003e \"Le titre\"}\n\nassert title == \"Le titre\"\n```\n\n#### Locale and fallback locale\n\nIf you do not specify the locale to translate to, the library will use the global\n[Gettext](https://hexdocs.pm/gettext/Gettext.html) default locale:\n\n```elixir\nconfig :gettext, :default_locale, \"fr\" # in your `mix.exs` config file\n\ntitle = Translator.translate(post.title)\n\nassert title == \"Le titre\"\n```\n\nThe global locale can be set through a Plug based on the website's host or path (see included plugs\nbelow).\n\nA fallback locale can be given as an option. In the example below, we try to translate the title in\nDutch, but no translation in Dutch has been provided. The translator will then use the given\nfallback locale:\n\n```elixir\ntitle = Translator.translate(post.title, \"nl\", fallback_locale: \"en\")\n\nassert title == \"The title\"\n```\n\nThe default fallback locale is the global Gettext default locale.\n\nIn case a translation is missing, the translator returns an empty string:\n\n```elixir\npost =\n  %Post{\n    title: %{\"en\" =\u003e \"The title\"},\n    body: %{\"en\" =\u003e \"The content\", \"fr\" =\u003e \"Le contenu\"}\n  }\n\ntranslated_post = Translator.translate(post, \"fr\")\n\nassert translated_post.translated_title == \"\"\n```\n\nIf instead you want an error to raise when a translation is missing, you can use the\nbang version of the translate function `translate!/3`.\n\n#### Handling missing translations\n\nYou may provide a callback to handle missing translations:\n\n```elixir\nTranslator.translate(%{\"fr\" =\u003e \"bonjour\"}, \"en\",\n  handle_missing_translation: fn translations_map, locale -\u003e\n\n    # add here your error handling stuff,\n    # e.g. notify yourself that a translation is missing\n\n    assert translations_map == %{\"fr\" =\u003e \"bonjour\"}\n    assert locale == \"en\"\n  end\n)\n```\n\n```elixir\npost = %Post{\n    title: %{\"en\" =\u003e \"The title\"},\n    body: %{\"en\" =\u003e \"The content\", \"fr\" =\u003e \"Le contenu\"}\n}\n\nTranslator.translate(post, \"fr\",\n  handle_missing_field_translation: fn field, translations_map, locale -\u003e\n\n    # add here your error handling stuff,\n    # e.g. notify yourself that a translation is missing\n\n    assert field == :title\n    assert translations_map == %{\"en\" =\u003e \"The title\"}\n    assert locale == \"fr\"\n  end\n)\n```\n\nIt can be quite tedious to pass your custom callback function to every `translate/3` call; you can\navoid this by wrapping `translate/3` in your own function, where you setup the commonly used\noptions. You can then import it for every controller through `MyAppWeb.controller/0`. Below is an\nexample where we want to raise an error when a translation is not found:\n\n```elixir\ndefmodule MyTranslator do\n  alias I18nHelpers.Ecto.Translator\n\n  def translate(data_structure, locale \\\\ Gettext.get_locale(), opts \\\\ []) do\n\n    handle_missing_translation =\n      Keyword.get(opts, :handle_missing_translation, \u0026handle_missing_translation/2)\n\n    opts =\n      Keyword.put(opts, :handle_missing_translation, handle_missing_translation)\n\n    Translator.translate(data_structure, locale, opts)\n  end\n\n  def handle_missing_translation(translations_map, locale) do\n    raise \"missing translation for locale `#{locale}` in #{inspect(translations_map)}\"\n  end\nend\n```\n\n## Phoenix Form helpers\n\nYou may render form inputs for your translation maps using the usual `Phoenix.HTML.Form` view helpers\nas shown below:\n\n```elixir\n\u003c%= text_input f, :title_en, name: \"post[title][en]\", value: Map.get(f.data.title, \"en\", \"\") %\u003e\n```\n\nHowever code written in templates should be simple and easier to read. This library provides view\nhelpers that allow writing form input fields in a more concise and clean way. Open up the entrypoint\nfor defining your web interface, such as `MyAppWeb`, and add the line below into the `view` function's\n`quote` block.\n\n```elixir\ndef view do\n  quote do\n    # some code\n    import I18nHelpers.HTML.InputHelpers\n  end\nend\n```\n\nHelpers below render a single input:\n\n```elixir\n\u003c%= translated_text_input f, :title, :en %\u003e\n```\n\n```elixir\n\u003c%= translated_textarea f, :title, :en %\u003e\n```\n\nYou may also render all the inputs (for all languages) for a field in one line:\n\n```elixir\ntranslated_text_inputs(f, :title, [:en, :fr])\ntranslated_text_inputs(f, :title, MyApp.Gettext) # will call Gettext.known_locales/1 on given Gettext backend\n```\n\n```elixir\ntranslated_textareas(f, :title, [:en, :fr])\n```\n\nIf you need custom labels and styling, you may pass options allowing you to\nadd labels and wrap the generated inputs with custom HTML elements:\n\n```elixir\ntranslated_text_inputs(f, :title, [:en, :fr],\n    labels: fn locale -\u003e content_tag(:i, locale) end,\n    wrappers: fn _locale -\u003e {:div, class: \"translated-input-wrapper\"} end\n)\n```\n\n## Fetch the locale from the URL\n\nThe library provides a set of plugs with different strategies to fetch the locale from the URL.\n\nThe plug will assign the locale to the [Connection](https://hexdocs.pm/plug/Plug.Conn.html) and\nset the [Gettext](https://hexdocs.pm/gettext/Gettext.html) locale.\n\nYou can retrieve the locale from the request path:\n\n```elixir\nplug I18nHelpers.Plugs.PutLocaleFromPath,\n  allowed_locales: [\"en\", \"fr\"],\n  default_locale: \"en\"\n```\n\nSee tests below:\n\n```elixir\nalias I18nHelpers.Plugs.PutLocaleFromPath\n\noptions = PutLocaleFromPath.init(allowed_locales: [\"fr\", \"nl\"], default_locale: \"en\")\n\nconn = conn(:get, \"https://example.com/fr/bonjour\")\nconn = PutLocaleFromPath.call(conn, options)\n\nassert conn.assigns == %{locale: \"fr\"}\nassert Gettext.get_locale == \"fr\"\n\nconn = conn(:get, \"https://example.com/hello\") # locale is not specified in path, use `default_locale`\nconn = PutLocaleFromPath.call(conn, options)\n\nassert conn.assigns == %{locale: \"en\"}\nassert Gettext.get_locale == \"en\"\n```\n\nOr from the subdomain:\n\n```elixir\nplug I18nHelpers.Plugs.PutLocaleFromSubdomain,\n  allowed_locales: [\"en\", \"fr\"],\n  default_locale: \"en\"\n```\n\nTests:\n\n```elixir\nalias I18nHelpers.Plugs.PutLocaleFromSubdomain\n\noptions = PutLocaleFromSubdomain.init(allowed_locales: [\"en\", \"fr\"], default_locale: \"en\")\n\nconn = conn(:get, \"https://fr.example.com/bonjour\")\nconn = PutLocaleFromSubdomain.call(conn, options)\n\nassert conn.assigns == %{locale: \"fr\"}\nassert Gettext.get_locale == \"fr\"\n\nconn = conn(:get, \"https://example.com/hello\") # locale is not specified in subdomain, use `default_locale`\nconn = PutLocaleFromSubdomain.call(conn, options)\n\nassert conn.assigns == %{locale: \"en\"}\nassert Gettext.get_locale == \"en\"\n```\n\nOr from the domain:\n\n```elixir\nplug I18nHelpers.Plugs.PutLocaleFromDomain,\n  domains_locales_map: %{\n    \"my-awesome-website.example\" =\u003e \"en\",\n    \"mon-super-site.example\" =\u003e \"fr\"\n  },\n  allowed_locales: [\"en\", \"fr\"],\n  default_locale: \"en\"\n```\n\nTests:\n\n```elixir\nalias I18nHelpers.Plugs.PutLocaleFromDomain\n\noptions =\n  PutLocaleFromDomain.init(\n    domains_locales_map: %{\n      \"my-awesome-website.example\" =\u003e \"en\",\n      \"mon-super-site.example\" =\u003e \"fr\"\n    },\n    allowed_locales: [\"en\", \"fr\"],\n    default_locale: \"en\"\n  )\n\nconn = conn(:get, \"https://my-awesome-website.example/hello\")\nconn = PutLocaleFromDomain.call(conn, options)\n\nassert conn.assigns == %{locale: \"en\"}\nassert Gettext.get_locale == \"en\"\n\nconn = conn(:get, \"https://mon-super-site.example/bonjour\")\nconn = PutLocaleFromDomain.call(conn, options)\n\nassert conn.assigns == %{locale: \"fr\"}\nassert Gettext.get_locale == \"fr\"\n\nconn = conn(:get, \"https://another-domain.example/hello\") # domain not found in `domains_locales_map`, use `default_locale`\nconn = PutLocaleFromDomain.call(conn, options)\n\nassert conn.assigns == %{locale: \"en\"}\nassert Gettext.get_locale == \"en\"\n```\n\nor from your custom function:\n\n```elixir\nalias I18nHelpers.Plugs.PutLocale\n\ndefp find_locale(conn) do\n  case conn.host do\n    \"en.example.com\" -\u003e\n      \"en\"\n\n    \"nl.example.com\" -\u003e\n      \"nl\"\n\n    _ -\u003e\n      case conn.path_info do\n        [\"en\" | _] -\u003e \"en\"\n        [\"nl\" | _] -\u003e \"nl\"\n        _ -\u003e \"en\"\n      end\n  end\nend\n\noptions = PutLocale.init(find_locale: \u0026find_locale/1)\n\nconn = conn(:get, \"/nl/hallo\")\nconn = PutLocale.call(conn, options)\n\nassert conn.assigns == %{locale: \"nl\"}\nassert Gettext.get_locale == \"nl\"\n```\n\n## Installation\n\nAdd `i18n_helpers` for Elixir as a dependency in your `mix.exs` file:\n\n```elixir\ndef deps do\n  [\n    {:i18n_helpers, \"~\u003e 0.14\"}\n  ]\nend\n```\n\n## HexDocs\n\nHexDocs documentation can be found at [https://hexdocs.pm/i18n_helpers](https://hexdocs.pm/i18n_helpers).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmathieuprog%2Fi18n_helpers","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmathieuprog%2Fi18n_helpers","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmathieuprog%2Fi18n_helpers/lists"}