├── test ├── test_helper.exs ├── fr-FR.yml ├── compiler_test.exs ├── es.yml ├── en.exs ├── escaping_test.exs ├── memorized_vocabulary_test.exs └── vocabulary_test.exs ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ └── test.yml ├── .formatter.exs ├── config └── config.exs ├── .sobelow-conf ├── lib ├── linguist │ ├── cldr_backend.ex │ ├── vocabulary.ex │ ├── compiler.ex │ └── memorized_vocabulary.ex └── linguist.ex ├── .gitignore ├── LICENSE ├── mix.exs ├── README.md ├── CHANGELOG.md └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/fr-FR.yml: -------------------------------------------------------------------------------- 1 | --- 2 | flash: 3 | notice: 4 | alert: "Ennui" 5 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Tag Change.org on changes to repo 2 | * @change/co-elixir 3 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :linguist, pluralization_key: :count 4 | 5 | config :ex_cldr, json_library: Jason 6 | 7 | if Mix.env() == :test do 8 | config :linguist, Linguist.Cldr, locales: ["fr", "en", "es"] 9 | end 10 | -------------------------------------------------------------------------------- /test/compiler_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CompilerTest do 2 | use ExUnit.Case 3 | 4 | test "compiles keyword list of translations into function def AST" do 5 | assert Linguist.Compiler.compile(en: [foo: "bar"], fr: [foo: "bar"]) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /.sobelow-conf: -------------------------------------------------------------------------------- 1 | [ 2 | verbose: false, 3 | private: false, 4 | skip: true, 5 | router: "", 6 | exit: "false", 7 | format: "txt", 8 | out: "", 9 | threshold: "low", 10 | ignore: ["Config.CSRF", "Config.HTTPS"], 11 | ignore_files: [""] 12 | ] 13 | -------------------------------------------------------------------------------- /lib/linguist/cldr_backend.ex: -------------------------------------------------------------------------------- 1 | defmodule Linguist.Cldr do 2 | @moduledoc """ 3 | Backend Module for Cldr App configuration, required for ~> 2.0. 4 | """ 5 | use Cldr, 6 | otp_app: :linguist, 7 | providers: [], 8 | data_dir: "./priv/cldr", 9 | default_locale: "en" 10 | end 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "mix" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | open-pull-requests-limit: 5 9 | 10 | - package-ecosystem: "docker" 11 | directory: "/" 12 | schedule: 13 | interval: "weekly" 14 | -------------------------------------------------------------------------------- /test/es.yml: -------------------------------------------------------------------------------- 1 | --- 2 | foo: "bar" 3 | flash: 4 | notice: 5 | alert: "Alerta!" 6 | hello: "hola %{first} %{last}" 7 | bye: "adios, %{name}!" 8 | users: 9 | title: "Usuarios" 10 | profiles: 11 | title: "Perfiles" 12 | escaped: "%%{escaped}" 13 | apple: 14 | one: "%{count} manzana" 15 | other: "%{count} manzanas" 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | .tmux.rb 6 | 7 | # OSX's favorite useless file 8 | .DS_Store 9 | 10 | # VS Code plugin 11 | /.elixir_ls/ 12 | 13 | # cldr locale files 14 | /priv/cldr 15 | 16 | # Generated sobelow file 17 | .sobelow 18 | 19 | /doc 20 | 21 | # Ignore tarball, built by "mix hex.build" 22 | linguist-*.tar 23 | -------------------------------------------------------------------------------- /test/en.exs: -------------------------------------------------------------------------------- 1 | [ 2 | foo: "bar", 3 | flash: [ 4 | notice: [ 5 | alert: "Alert!", 6 | hello: "hello %{first} %{last}", 7 | bye: "bye now, %{name}!" 8 | ] 9 | ], 10 | users: [ 11 | title: "Users", 12 | profiles: [ 13 | title: "Profiles" 14 | ] 15 | ], 16 | escaped: "%%{escaped}", 17 | apple: [ 18 | one: "%{count} apple", 19 | other: "%{count} apples" 20 | ] 21 | ] 22 | -------------------------------------------------------------------------------- /lib/linguist.ex: -------------------------------------------------------------------------------- 1 | defmodule Linguist do 2 | @moduledoc false 3 | defmodule NoTranslationError do 4 | defexception [:message] 5 | 6 | def exception(message) do 7 | %NoTranslationError{message: "No translation found for #{message}"} 8 | end 9 | end 10 | 11 | defmodule LocaleError do 12 | defexception [:message] 13 | 14 | @impl true 15 | def exception(value) do 16 | msg = 17 | "Invalid locale: expected a locale in the format 'es-ES' or 'es', but received: #{value}" 18 | 19 | %LocaleError{message: msg} 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/escaping_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EscapingTest do 2 | use ExUnit.Case 3 | 4 | defmodule Esc do 5 | use Linguist.Vocabulary 6 | 7 | locale("en", []) 8 | 9 | locale( 10 | "fr", 11 | level: [ 12 | basic: "%%{escaped}", 13 | mixed: "%{a} %%{a} %{a} %%{a}" 14 | ] 15 | ) 16 | end 17 | 18 | test "t does not escape %%{ but replaces %% by %" do 19 | assert Esc.t!("fr", "level.basic") == "%{escaped}" 20 | end 21 | 22 | test "even if key is in the binding" do 23 | assert Esc.t!("fr", "level.basic", escaped: "Does not matter") == "%{escaped}" 24 | end 25 | 26 | test "mixed form" do 27 | assert Esc.t!("fr", "level.mixed", a: 42) == "42 %{a} 42 %{a}" 28 | end 29 | 30 | test "mixed form, no values" do 31 | assert_raise KeyError, fn -> 32 | Esc.t!("fr", "level.mixed") 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Chris McCord 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | Code.ensure_loaded?(Hex) and Hex.start() 2 | 3 | defmodule Linguist.MixProject do 4 | @moduledoc false 5 | use Mix.Project 6 | 7 | @version "0.5.0" 8 | 9 | def project do 10 | [ 11 | app: :linguist, 12 | version: @version, 13 | elixir: "~> 1.14", 14 | deps: deps(), 15 | package: package(), 16 | description: description(), 17 | name: "Linguist", 18 | source_url: source_url(), 19 | docs: [ 20 | extras: ["README.md"], 21 | main: "readme", 22 | source_ref: "v#{@version}", 23 | source_url: source_url() 24 | ] 25 | ] 26 | end 27 | 28 | def application do 29 | [] 30 | end 31 | 32 | defp package do 33 | [ 34 | maintainers: ["Change.org"], 35 | licenses: ["MIT"], 36 | links: %{github: source_url()} 37 | ] 38 | end 39 | 40 | defp source_url do 41 | "https://github.com/change/linguist" 42 | end 43 | 44 | defp description do 45 | "Elixir Internationalization library, extended to support translation files in the rails-i18n format." 46 | end 47 | 48 | defp deps do 49 | [ 50 | {:credo, "~> 1.0", only: [:dev, :test], runtime: false}, 51 | {:ex_cldr, "~> 2.37"}, 52 | {:ex_doc, "~> 0.36", only: :docs, runtime: false}, 53 | {:jason, "~> 1.0"}, 54 | {:sobelow, "~> 0.10", only: :dev, runtime: false}, 55 | {:yaml_elixir, "~> 2.0"} 56 | ] 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/memorized_vocabulary_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MemorizedVocabularyTest do 2 | use ExUnit.Case 3 | 4 | setup do 5 | Linguist.MemorizedVocabulary.locale("es", Path.join([__DIR__, "es.yml"])) 6 | Linguist.MemorizedVocabulary.locale("fr-FR", Path.join([__DIR__, "fr-FR.yml"])) 7 | :ok 8 | end 9 | 10 | test "locales() returns locales" do 11 | assert ["fr-FR", "es"] == Linguist.MemorizedVocabulary.locales() 12 | end 13 | 14 | test "t returns a translation" do 15 | assert {:ok, "bar"} == Linguist.MemorizedVocabulary.t("es", "foo") 16 | end 17 | 18 | test "t interpolates values" do 19 | assert {:ok, "hola Michael Westin"} == 20 | Linguist.MemorizedVocabulary.t( 21 | "es", 22 | "flash.notice.hello", 23 | first: "Michael", 24 | last: "Westin" 25 | ) 26 | end 27 | 28 | test "t returns {:error, :no_translation} when translation is missing" do 29 | assert Linguist.MemorizedVocabulary.t("es", "flash.not_exists") == {:error, :no_translation} 30 | end 31 | 32 | test "t! raises NoTranslationError when translation is missing" do 33 | assert_raise Linguist.NoTranslationError, fn -> 34 | Linguist.MemorizedVocabulary.t!("es", "flash.not_exists") 35 | end 36 | end 37 | 38 | test "t pluralizes" do 39 | assert {:ok, "2 manzanas"} == Linguist.MemorizedVocabulary.t("es", "apple", count: 2) 40 | end 41 | 42 | test "t will normalize a locale to format ll-LL" do 43 | assert {:ok, "Ennui"} == Linguist.MemorizedVocabulary.t("FR-fr", "flash.notice.alert") 44 | end 45 | 46 | test "t will raise a LocaleError if a malformed locale is passed in" do 47 | assert_raise Linguist.LocaleError, fn -> 48 | Linguist.MemorizedVocabulary.t("es-es-es", "flash.notice.alert") 49 | end 50 | 51 | assert_raise Linguist.LocaleError, fn -> 52 | Linguist.MemorizedVocabulary.t(nil, "flash.notice.alert") 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Linguist 2 | 3 | [![Test](https://github.com/change/linguist/actions/workflows/test.yml/badge.svg)](https://github.com/change/linguist/actions/workflows/test.yml) 4 | [![Version on Hex.pm](https://img.shields.io/hexpm/v/linguist.svg)](https://hex.pm/packages/linguist) 5 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/linguist) 6 | [![Hex.pm downloads](https://img.shields.io/hexpm/dt/linguist.svg)](https://hex.pm/packages/linguist) 7 | [![License](https://img.shields.io/hexpm/l/linguist.svg)](https://github.com/change/linguist/blob/main/LICENSE) 8 | [![Latest commit](https://img.shields.io/github/last-commit/change/linguist.svg)](https://github.com/change/linguist/commits/main) 9 | ![GitHub top language](https://img.shields.io/github/languages/top/change/linguist) 10 | 11 | > Linguist is a simple Elixir Internationalization library 12 | 13 | ## Installation 14 | 15 | Add `:linguist` to your `mix.exs` dependencies: 16 | 17 | ```elixir 18 | def deps do 19 | [ 20 | {:linguist, "~> 0.5"} 21 | ] 22 | end 23 | ``` 24 | 25 | Update your dependencies: 26 | 27 | ```bash 28 | $ mix deps.get 29 | ``` 30 | 31 | ## Usage 32 | 33 | ```elixir 34 | defmodule I18n do 35 | use Linguist.Vocabulary 36 | 37 | locale "en", [ 38 | flash: [ 39 | notice: [ 40 | hello: "hello %{first} %{last}", 41 | bye: "bye now, %{name}!" 42 | ] 43 | ], 44 | users: [ 45 | title: "Users", 46 | profiles: [ 47 | title: "Profiles", 48 | ] 49 | ] 50 | ] 51 | 52 | locale "fr", Path.join([__DIR__, "fr.exs"]) 53 | end 54 | 55 | # fr.exs 56 | [ 57 | flash: [ 58 | notice: [ 59 | hello: "salut %{first} %{last}" 60 | ] 61 | ] 62 | ] 63 | 64 | iex> I18n.t!("en", "flash.notice.hello", first: "chris", last: "mccord") 65 | "hello chris mccord" 66 | 67 | iex> I18n.t!("fr", "flash.notice.hello", first: "chris", last: "mccord") 68 | "salut chris mccord" 69 | 70 | iex> I18n.t!("en", "users.title") 71 | "Users" 72 | ``` 73 | 74 | ## Configuration 75 | 76 | The key to use for pluralization is configurable, and should likely be an atom: 77 | 78 | ```elixir 79 | config :linguist, pluralization_key: :count 80 | ``` 81 | will cause the system to pluralize based on the `count` parameter passed to the `t` function. 82 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | name: Elixir ${{matrix.elixir}} / OTP ${{matrix.otp}} 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | include: 17 | - elixir: '1.14' 18 | otp: '25' 19 | - elixir: '1.15' 20 | otp: '25' 21 | - elixir: '1.16' 22 | otp: '26' 23 | - elixir: '1.17' 24 | otp: '27' 25 | - elixir: '1.18' 26 | otp: '27' 27 | lint: true 28 | 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v5 32 | 33 | - name: Set up Elixir 34 | uses: erlef/setup-beam@v1 35 | with: 36 | elixir-version: ${{ matrix.elixir }} 37 | otp-version: ${{ matrix.otp }} 38 | 39 | - name: Restore deps cache 40 | uses: actions/cache@v4 41 | with: 42 | path: deps 43 | key: ${{ runner.os }}-mix-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 44 | restore-keys: | 45 | ${{ runner.os }}-mix-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 46 | ${{ runner.os }}-mix-${{ matrix.otp }}-${{ matrix.elixir }} 47 | 48 | - name: Restore _build cache 49 | uses: actions/cache@v4 50 | with: 51 | path: _build 52 | key: ${{ runner.os }}-build-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 53 | restore-keys: | 54 | ${{ runner.os }}-build-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 55 | ${{ runner.os }}-build-${{ matrix.otp }}-${{ matrix.elixir }} 56 | 57 | - name: Install hex 58 | run: mix local.hex --force 59 | 60 | - name: Install rebar 61 | run: mix local.rebar --force 62 | 63 | - name: Install package dependencies 64 | run: mix deps.get 65 | 66 | - name: Validate format 67 | run: mix format --check-formatted 68 | if: ${{ matrix.lint }} 69 | 70 | - name: Remove compiled application files 71 | run: mix clean 72 | 73 | - name: Compile dependencies 74 | run: mix compile 75 | 76 | - name: Compile with warnings as errors 77 | run: mix compile --warnings-as-errors 78 | if: ${{ matrix.lint }} 79 | 80 | - name: Run unit tests 81 | run: mix test 82 | 83 | - name: Build docs (validate build) 84 | run: MIX_ENV=docs mix docs 85 | if: ${{ matrix.lint }} 86 | -------------------------------------------------------------------------------- /lib/linguist/vocabulary.ex: -------------------------------------------------------------------------------- 1 | defmodule Linguist.Vocabulary do 2 | alias Linguist.Compiler 3 | 4 | @moduledoc """ 5 | Defines lookup functions for given translation locales, binding interpolation. 6 | 7 | Locales are defined with the `locale/2` macro, accepting a locale name and 8 | either keyword list of translations or String path to evaluate for 9 | translations list. 10 | 11 | For example, given the following translations: 12 | 13 | locale "en", [ 14 | flash: [ 15 | notice: [ 16 | hello: "hello %{first} %{last}", 17 | ] 18 | ], 19 | users: [ 20 | title: "Users", 21 | ] 22 | ] 23 | 24 | locale "fr", Path.join([__DIR__, "fr.exs"]) 25 | 26 | This module will compile this down to these functions: 27 | 28 | def t("en", "flash.notice.hello", bindings \\ []), do: # ... 29 | def t("en", "users.title", bindings \\ []), do: # ... 30 | def t("fr", "flash.notice.hello", bindings \\ []), do: # ... 31 | 32 | """ 33 | 34 | @doc """ 35 | Compiles all the translations and inject the functions created in the current module. 36 | """ 37 | defmacro __using__(_options) do 38 | quote do 39 | Module.register_attribute(__MODULE__, :locales, accumulate: true, persist: false) 40 | import unquote(__MODULE__) 41 | @before_compile unquote(__MODULE__) 42 | end 43 | end 44 | 45 | defmacro __before_compile__(env) do 46 | Compiler.compile(Module.get_attribute(env.module, :locales)) 47 | end 48 | 49 | @doc """ 50 | Embeds locales from provided source. 51 | 52 | * name - The String name of the locale, ie "en", "fr" 53 | * source - 54 | 1. The String file path to eval that returns a keyword list of translations 55 | 2. The Keyword List of translations 56 | 57 | ## Examples 58 | 59 | locale "en", [ 60 | flash: [ 61 | notice: [ 62 | hello: "hello %{first} %{last}", 63 | ] 64 | ] 65 | ] 66 | 67 | locale "fr", Path.join([__DIR__, "fr.exs"]) 68 | """ 69 | defmacro locale(name, source) do 70 | quote bind_quoted: [name: name, source: source] do 71 | loaded_source = 72 | cond do 73 | is_binary(source) && String.ends_with?(source, [".yml", ".yaml"]) -> 74 | Linguist.Vocabulary._load_yaml_file(source) 75 | 76 | is_binary(source) -> 77 | @external_resource source 78 | source |> Code.eval_file() |> elem(0) 79 | 80 | true -> 81 | source 82 | end 83 | 84 | name = name |> to_string() 85 | 86 | @locales {name, loaded_source} 87 | end 88 | end 89 | 90 | @doc """ 91 | Function used internally to load a yaml file. 92 | 93 | Please use the `locale` macro with a path to a yaml file - this function 94 | will not work as expected if called directly. 95 | """ 96 | def _load_yaml_file(source) do 97 | {:ok, [result]} = YamlElixir.read_all_from_file(source) 98 | 99 | result 100 | |> Enum.reduce([], &Linguist.Vocabulary._yaml_reducer/2) 101 | end 102 | 103 | @doc """ 104 | Recursive function used internally for loading yaml files. 105 | 106 | Not intended for external use 107 | """ 108 | # sobelow_skip ["DOS.StringToAtom"] 109 | def _yaml_reducer({key, value}, acc) when is_binary(value) do 110 | [{String.to_atom(key), value} | acc] 111 | end 112 | 113 | # sobelow_skip ["DOS.StringToAtom"] 114 | def _yaml_reducer({key, value}, acc) do 115 | [{String.to_atom(key), Enum.reduce(value, [], &Linguist.Vocabulary._yaml_reducer/2)} | acc] 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /test/vocabulary_test.exs: -------------------------------------------------------------------------------- 1 | defmodule VocabularyTest do 2 | use ExUnit.Case 3 | 4 | defmodule I18n do 5 | use Linguist.Vocabulary 6 | locale("es", Path.join([__DIR__, "es.yml"])) 7 | 8 | locale("en", Path.join([__DIR__, "en.exs"])) 9 | 10 | locale( 11 | "fr", 12 | flash: [ 13 | notice: [ 14 | hello: "salut %{first} %{last}" 15 | ], 16 | interpolation_at_beginning: "%{name} at beginning" 17 | ], 18 | apple: [ 19 | one: "%{count} Pomme", 20 | other: "%{count} pommes" 21 | ] 22 | ) 23 | end 24 | 25 | test "it returns locales" do 26 | assert ["fr", "en", "es"] == I18n.locales() 27 | end 28 | 29 | test "it handles both string and atom locales" do 30 | assert I18n.t!("en", "foo") == I18n.t!(:en, "foo") 31 | assert I18n.t("en", "foo") == I18n.t(:en, "foo") 32 | end 33 | 34 | test "it handles translations at rool level" do 35 | assert I18n.t!("en", "foo") == "bar" 36 | assert I18n.t("en", "foo") == {:ok, "bar"} 37 | end 38 | 39 | test "it handles nested translations" do 40 | assert I18n.t!("en", "flash.notice.alert") == "Alert!" 41 | assert I18n.t("en", "flash.notice.alert") == {:ok, "Alert!"} 42 | end 43 | 44 | test "it recursively walks translations tree" do 45 | assert I18n.t!("en", "users.title") == "Users" 46 | assert I18n.t("en", "users.title") == {:ok, "Users"} 47 | assert I18n.t!("en", "users.profiles.title") == "Profiles" 48 | assert I18n.t("en", "users.profiles.title") == {:ok, "Profiles"} 49 | end 50 | 51 | test "it interpolates bindings" do 52 | assert I18n.t!("en", "flash.notice.hello", first: "chris", last: "mccord") == 53 | "hello chris mccord" 54 | 55 | assert I18n.t("en", "flash.notice.hello", first: "chris", last: "mccord") == 56 | {:ok, "hello chris mccord"} 57 | 58 | assert I18n.t!("en", "flash.notice.bye", name: "chris") == "bye now, chris!" 59 | assert I18n.t("en", "flash.notice.bye", name: "chris") == {:ok, "bye now, chris!"} 60 | end 61 | 62 | test "t raises KeyError when bindings not provided" do 63 | assert_raise KeyError, fn -> 64 | I18n.t("en", "flash.notice.hello", first: "chris") 65 | end 66 | end 67 | 68 | test "t! raises KeyError when bindings not provided" do 69 | assert_raise KeyError, fn -> 70 | I18n.t!("en", "flash.notice.hello", first: "chris") 71 | end 72 | end 73 | 74 | test "it compiles all locales" do 75 | assert I18n.t!("fr", "flash.notice.hello", first: "chris", last: "mccord") == 76 | "salut chris mccord" 77 | 78 | assert I18n.t("fr", "flash.notice.hello", first: "chris", last: "mccord") == 79 | {:ok, "salut chris mccord"} 80 | end 81 | 82 | test "t! raises NoTranslationError when translation is missing" do 83 | assert_raise Linguist.NoTranslationError, fn -> 84 | I18n.t!("en", "flash.not_exists") 85 | end 86 | end 87 | 88 | test "t returns {:error, :no_translation} when translation is missing" do 89 | assert I18n.t("en", "flash.not_exists") == {:error, :no_translation} 90 | end 91 | 92 | test "converts interpolation values to string" do 93 | assert I18n.t!("fr", "flash.notice.hello", first: 123, last: "mccord") == "salut 123 mccord" 94 | end 95 | 96 | test "interpolations can exist as the first segment of the translation" do 97 | assert I18n.t!("fr", "flash.interpolation_at_beginning", name: "chris") == 98 | "chris at beginning" 99 | end 100 | 101 | describe "pluralizations" do 102 | test "pluralizes English correctly" do 103 | assert I18n.t!("en", "apple", count: 1) == "1 apple" 104 | assert I18n.t!("en", "apple", count: 2) == "2 apples" 105 | end 106 | 107 | test "pluralizes Spanish correctly" do 108 | assert I18n.t!("es", "apple", count: 1) == "1 manzana" 109 | assert I18n.t!("es", "apple", count: 2) == "2 manzanas" 110 | end 111 | 112 | test "throws an error when a pluralized string is not given a count" do 113 | assert_raise Linguist.NoTranslationError, fn -> 114 | I18n.t!("en", "apple") 115 | end 116 | end 117 | end 118 | 119 | test "translations in yaml files are loaded successfully" do 120 | assert I18n.t!("es", "flash.notice.hello", first: 123, last: "mccord") == "hola 123 mccord" 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/linguist/compiler.ex: -------------------------------------------------------------------------------- 1 | defmodule Linguist.Compiler do 2 | @moduledoc """ 3 | Translation Compiler Module. 4 | """ 5 | alias Linguist.Cldr.Number.Cardinal 6 | alias Linguist.NoTranslationError 7 | 8 | @doc ~S""" 9 | Compiles keyword list of transactions into function definitions AST. 10 | 11 | ## Examples 12 | 13 | iex> Linguist.Compiler.compile(en: [ 14 | hello: "Hello %{name}", 15 | alert: "Alert!" 16 | ]) 17 | 18 | quote do 19 | def t(locale, path, binding \\ []) 20 | 21 | def t("en", "hello", bindings), do: "Hello " <> Keyword.fetch!(bindings, :name) 22 | def t("en", "alert", bindings), do: "Alert!" 23 | 24 | def t(_locale, _path, _bindings), do: {:error, :no_translation} 25 | def t!(locale, path, bindings \\ []) do 26 | case t(locale, path, bindings) do 27 | {:ok, translation} -> translation 28 | {:error, :no_translation} -> 29 | raise %NoTranslationError{message: "#{locale}: #{path}"} 30 | end 31 | end 32 | end 33 | """ 34 | 35 | @interpol_rgx ~r/ 36 | (?) 37 | (?) 39 | /x 40 | def interpol_rgx do 41 | @interpol_rgx 42 | end 43 | 44 | @escaped_interpol_rgx ~r/%%{/ 45 | @simple_interpol "%{" 46 | 47 | # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity 48 | def compile(translations) do 49 | langs = 50 | translations 51 | |> Enum.reduce([], fn item, acc -> 52 | case item do 53 | {name, _paths} -> acc ++ [to_string(name)] 54 | _ -> acc 55 | end 56 | end) 57 | 58 | translations = 59 | for {locale, source} <- translations do 60 | deftranslations(to_string(locale), "", source) 61 | end 62 | 63 | quote do 64 | def t(locale, path, binding \\ []) 65 | 66 | def t(locale, path, binding) when is_atom(locale) do 67 | t(to_string(locale), path, binding) 68 | end 69 | 70 | def t(locale, path, bindings) do 71 | pluralization_key = Application.fetch_env!(:linguist, :pluralization_key) 72 | 73 | if Keyword.has_key?(bindings, pluralization_key) do 74 | plural_atom = 75 | bindings 76 | |> Keyword.get(pluralization_key) 77 | |> Cardinal.plural_rule(locale) 78 | 79 | new_path = "#{path}.#{plural_atom}" 80 | do_t(locale, new_path, bindings) 81 | else 82 | do_t(locale, path, bindings) 83 | end 84 | end 85 | 86 | unquote(translations) 87 | 88 | def do_t(_locale, _path, _bindings), do: {:error, :no_translation} 89 | 90 | def t!(locale, path, bindings \\ []) do 91 | case t(locale, path, bindings) do 92 | {:ok, translation} -> 93 | translation 94 | 95 | {:error, :no_translation} -> 96 | raise %NoTranslationError{message: "#{locale}: #{path}"} 97 | end 98 | end 99 | 100 | def locales do 101 | unquote(langs) 102 | end 103 | end 104 | end 105 | 106 | defp deftranslations(locale, current_path, translations) do 107 | for {key, val} <- translations do 108 | path = append_path(current_path, key) 109 | 110 | if Keyword.keyword?(val) do 111 | deftranslations(locale, path, val) 112 | else 113 | quote do 114 | def do_t(unquote(locale), unquote(path), bindings) do 115 | {:ok, unquote(interpolate(val, :bindings))} 116 | end 117 | end 118 | end 119 | end 120 | end 121 | 122 | # sobelow_skip ["DOS.StringToAtom"] 123 | defp interpolate(string, var) do 124 | @interpol_rgx 125 | |> Regex.split(string, on: [:head, :tail]) 126 | |> Enum.reduce("", fn 127 | <<"%{" <> rest>>, acc -> 128 | key = String.to_atom(String.trim_trailing(rest, "}")) 129 | bindings = Macro.var(var, __MODULE__) 130 | 131 | quote do 132 | unquote(acc) <> to_string(Keyword.fetch!(unquote(bindings), unquote(key))) 133 | end 134 | 135 | segment, acc -> 136 | quote do: unquote(acc) <> unquote(unescape(segment)) 137 | end) 138 | end 139 | 140 | defp append_path("", next), do: to_string(next) 141 | defp append_path(current, next), do: "#{current}.#{next}" 142 | 143 | defp unescape(segment) do 144 | Regex.replace(@escaped_interpol_rgx, segment, @simple_interpol) 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.5.0 (2025-10-12) 4 | * **Now requires Elixir ~> 1.14** (previously ~> 1.11) 5 | * Update CI test matrix to include Elixir 1.14-1.18 6 | * Upgrade `ex_doc` from ~> 0.22 to ~> 0.36 7 | * Update `ex_cldr` from 2.37.5 to 2.43.2 8 | * Update `yaml_elixir` from 2.9.0 to 2.11.0 9 | * Update `jason` from 1.4.1 to 1.4.4 10 | * Update `credo` from 1.7.3 to 1.7.12 11 | * Remove poison 12 | * Update GitHub workflows 13 | * Update repository references from `master` to `main` branch 14 | 15 | ## v0.4.0 (2024-02-05) 16 | * Now requires Elixir ~> 1.11 17 | * Add Elixir 1.15 and OTP 25 to ci test matrix 18 | * Upgrade `ex_cldr` to version 2.37 19 | * Add mix docs to ci workflow, to ensure docs build after changes 20 | * Add Elixir 1.13 OTP 24 to ci test matrix 21 | * Remove unused `earmark` dep 22 | * Remove unnecessary `applications:` config causing error with `mix docs` 23 | * Update dependencies + credo fixes 24 | * Update readme, add status badges 25 | * Updates docs and typo fixes 26 | + Add dependabot and codeowner configs 27 | 28 | ## v0.3.2 (2021-08-11) 29 | Wow, I held on to this release waaaaaay too long. Sorry about that. Also, a *huge thank you to @dolfinus for their contributes* (and nudges)! 30 | 31 | Here's what changed: 32 | * [removes the cldr compiler](https://github.com/change/linguist/pull/28) 33 | * [improved readme syntax highlighting](https://github.com/change/linguist/pull/29) 34 | * [much improved handling of locales as atoms or binaries](https://github.com/change/linguist/pull/30) 35 | * [remove warning about 'always matches' function](https://github.com/change/linguist/pull/31) 36 | * [stop unnecessary local file downloads](https://github.com/change/linguist/pull/32) 37 | * [automatically start the cldr application](https://github.com/change/linguist/pull/33) 38 | * [add CI pipeline with github actions](https://github.com/change/linguist/pull/35) 39 | * [setup CI to use ubuntu 18](https://github.com/change/linguist/pull/36) 40 | 41 | ## v0.3.1 (2020-05-28) 42 | * Transfer the code to the "change" namespace (only an update to `mix.exs`). 43 | 44 | ## v0.3.0 (2020-04-28) 45 | * [Upgrade `ex_cldr` to version 2](https://github.com/mertonium/linguist/commit/b66681c4d66543829f1154af3e5a90a1fa93aca7). From the PR description (by @barrieloydall): 46 | > This PR updates ex_cldr to the latest 2.x` version which requires a few changes beyond a number version update. 47 | > 48 | > Some initial reading: https://github.com/elixir-cldr/cldr#getting-started 49 | > 50 | > We are now required to a have a backend module, which i've placed in cldr_backend.ex, this essentially acts as the public interface to the CLDR functionality and is used for some of the configuration now. 51 | > 52 | > Only `json_library` and `default_locale` can be defined in config, anything else will generate warnings for future deprecation. 53 | > 54 | > As we use Linguist within a couple of other apps, we need to specify an `otp_app` name. This allows for related config to be passed in by our other apps. This keeps linguist just using the 3 locales it previously defined: `config :linguist, Linguist.Cldr, locales: ["fr", "en", "es"]`. 55 | > 56 | > Now also defining the `data_dir`, and also ignoring it from git. Without this, I would run into an issue which I should go back and validate... 57 | * Add sobelow to the project. [Address the issues it flagged](https://github.com/mertonium/linguist/commit/e699c1274c3a4861288afa41cef3f1afe1cad9b6). 58 | * Add ex_doc and tidy up the generated documentation output 59 | 60 | ## v0.2.1 (2019-01-25) 61 | * [Add helper function](https://github.com/mertonium/linguist/commit/06807327e5095e54dd584ad5d65469e4358c92b4) for normalizing locales argument in MemorizedVocubalary.t/3. Locales will be made into the format "es-ES" or "es" 62 | 63 | ## v0.2.0 (2018-10-22) 64 | * **LARGE SCALE REFACTOR** described in [this pull request](https://github.com/mertonium/linguist/pull/22) 65 | 66 | ## v0.1.4 (2014-11-24) 67 | 68 | * Bug Fixes 69 | * Fix bug causing interpolations at beginning of string to be missed 70 | 71 | ## v0.1.0 (2014-07-06) 72 | 73 | * Enhancements 74 | * Add `locale` macro for locale definitions 75 | * Support String filepath locale source for automated evaluation 76 | * Support arbitrary locale source to fetch keyword list of translations, ie function call, Code.eval_file, etc. 77 | * Add `t!` lookups where `NoTranslationError` is raised if translation not found 78 | 79 | * Backwards incompatible changes 80 | * Rename `Linguist.Compiler` to `Linguist.Vocabulary` 81 | * Locale definitions now required to use `locale/2` macro instead of `use` options 82 | * Update `t` lookups to return `{:ok, translation}` or `{:error, :no_translation}` 83 | 84 | ## v0.0.1 (2014-06-28) 85 | 86 | Initial release 87 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "cldr_utils": {:hex, :cldr_utils, "2.29.1", "11ff0a50a36a7e5f3bd9fc2fb8486a4c1bcca3081d9c080bf9e48fe0e6742e2d", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "3844a0a0ed7f42e6590ddd8bd37eb4b1556b112898f67dea3ba068c29aabd6c2"}, 4 | "credo": {:hex, :credo, "1.7.14", "c7e75216cea8d978ba8c60ed9dede4cc79a1c99a266c34b3600dd2c33b96bc92", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "12a97d6bb98c277e4fb1dff45aaf5c137287416009d214fb46e68147bd9e0203"}, 5 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 7 | "ex_cldr": {:hex, :ex_cldr, "2.44.1", "0d220b175874e1ce77a0f7213bdfe700b9be11aefbf35933a0e98837803ebdc5", [:mix], [{:cldr_utils, "~> 2.28", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "3880cd6137ea21c74250cd870d3330c4a9fdec07fabd5e37d1b239547929e29b"}, 8 | "ex_doc": {:hex, :ex_doc, "0.39.2", "da5549bbce34c5fb0811f829f9f6b7a13d5607b222631d9e989447096f295c57", [:mix], [{:earmark_parser, "~> 1.4.44", [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", "62665526a88c207653dbcee2aac66c2c229d7c18a70ca4ffc7f74f9e01324daa"}, 9 | "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, 10 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 11 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 12 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [: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", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 13 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 14 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 15 | "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, 16 | "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, 17 | "yaml_elixir": {:hex, :yaml_elixir, "2.11.0", "9e9ccd134e861c66b84825a3542a1c22ba33f338d82c07282f4f1f52d847bd50", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "53cc28357ee7eb952344995787f4bb8cc3cecbf189652236e9b163e8ce1bc242"}, 18 | } 19 | -------------------------------------------------------------------------------- /lib/linguist/memorized_vocabulary.ex: -------------------------------------------------------------------------------- 1 | defmodule Linguist.MemorizedVocabulary do 2 | alias Linguist.Cldr.Number.Cardinal 3 | alias Linguist.{Compiler, LocaleError, NoTranslationError} 4 | 5 | defmodule TranslationDecodeError do 6 | defexception [:message] 7 | end 8 | 9 | @moduledoc """ 10 | Defines lookup functions for given translation locales, binding interpolation. 11 | 12 | Locales are defined with the `locale/2` function, accepting a locale name and 13 | a String path to evaluate for the translations list. 14 | 15 | For example, given the following translations: 16 | 17 | locale "en", [ 18 | flash: [ 19 | notice: [ 20 | hello: "hello %{first} %{last}", 21 | ] 22 | ], 23 | users: [ 24 | title: "Users", 25 | ] 26 | ] 27 | 28 | locale "fr", Path.join([__DIR__, "fr.exs"]) 29 | 30 | This module will respond to these functions: 31 | 32 | t("en", "flash.notice.hello", bindings \\ []), do: # ... 33 | t("en", "users.title", bindings \\ []), do: # ... 34 | t("fr", "flash.notice.hello", bindings \\ []), do: # ... 35 | 36 | """ 37 | def t(locale, path, bindings \\ []) 38 | def t(nil, _, _), do: raise(LocaleError, nil) 39 | 40 | def t(locale, path, binding) when is_atom(locale) do 41 | t(to_string(locale), path, binding) 42 | end 43 | 44 | def t(locale, path, bindings) do 45 | pluralization_key = Application.fetch_env!(:linguist, :pluralization_key) 46 | norm_locale = normalize_locale(locale) 47 | 48 | if Keyword.has_key?(bindings, pluralization_key) do 49 | plural_atom = 50 | bindings 51 | |> Keyword.get(pluralization_key) 52 | |> Cardinal.plural_rule(norm_locale) 53 | 54 | do_t(norm_locale, "#{path}.#{plural_atom}", bindings) 55 | else 56 | do_t(norm_locale, path, bindings) 57 | end 58 | end 59 | 60 | def t!(locale, path, bindings \\ []) do 61 | case t(locale, path, bindings) do 62 | {:ok, translation} -> 63 | translation 64 | 65 | {:error, :no_translation} -> 66 | raise %NoTranslationError{message: "#{locale}: #{path}"} 67 | end 68 | end 69 | 70 | # sobelow_skip ["DOS.StringToAtom"] 71 | defp do_t(locale, translation_key, bindings) do 72 | case :ets.lookup(:translations_registry, "#{locale}.#{translation_key}") do 73 | [] -> 74 | {:error, :no_translation} 75 | 76 | [{_, string}] -> 77 | translation = 78 | Compiler.interpol_rgx() 79 | |> Regex.split(string, on: [:head, :tail]) 80 | |> Enum.reduce("", fn 81 | <<"%{" <> rest>>, acc -> 82 | key = String.to_atom(String.trim_trailing(rest, "}")) 83 | 84 | acc <> to_string(Keyword.fetch!(bindings, key)) 85 | 86 | segment, acc -> 87 | acc <> segment 88 | end) 89 | 90 | {:ok, translation} 91 | end 92 | end 93 | 94 | def locales do 95 | tuple = 96 | :ets.lookup(:translations_registry, "memorized_vocabulary.locales") 97 | |> List.first() 98 | 99 | if tuple do 100 | elem(tuple, 1) 101 | end 102 | end 103 | 104 | def add_locale(name) do 105 | current_locales = locales() || [] 106 | 107 | :ets.insert( 108 | :translations_registry, 109 | {"memorized_vocabulary.locales", [name | current_locales]} 110 | ) 111 | end 112 | 113 | def update_translations(locale_name, loaded_source) do 114 | loaded_source 115 | |> Enum.map(fn {key, translation_string} -> 116 | :ets.insert(:translations_registry, {"#{locale_name}.#{key}", translation_string}) 117 | end) 118 | end 119 | 120 | @doc """ 121 | Embeds locales from provided source. 122 | 123 | * name - The String name of the locale, ie "en", "fr" 124 | * source - The String file path to load YAML from that returns a structured list of translations 125 | 126 | ## Examples 127 | 128 | locale "es", Path.join([__DIR__, "es.yml"]) 129 | 130 | """ 131 | def locale(name, source) do 132 | loaded_source = Linguist.MemorizedVocabulary._load_yaml_file(source) 133 | update_translations(name, loaded_source) 134 | add_locale(name) 135 | end 136 | 137 | @doc """ 138 | Function used internally to load a yaml file. 139 | 140 | Please use the `locale` macro with a path to a yaml file - this function 141 | will not work as expected if called directly. 142 | """ 143 | def _load_yaml_file(source) do 144 | if :ets.info(:translations_registry) == :undefined do 145 | :ets.new(:translations_registry, [:named_table, :set, :protected]) 146 | end 147 | 148 | {decode_status, [file_data]} = YamlElixir.read_all_from_file(source) 149 | 150 | if decode_status != :ok do 151 | raise %TranslationDecodeError{message: "Decode failed for file #{source}"} 152 | end 153 | 154 | %{paths: paths} = 155 | file_data 156 | |> Enum.reduce( 157 | %{paths: %{}, current_prefix: ""}, 158 | &Linguist.MemorizedVocabulary._yaml_reducer/2 159 | ) 160 | 161 | paths 162 | end 163 | 164 | @doc """ 165 | Recursive function used internally for loading yaml files. 166 | 167 | Not intended for external use 168 | """ 169 | def _yaml_reducer({key, value}, acc) when is_binary(value) do 170 | key_name = 171 | if acc.current_prefix == "" do 172 | key 173 | else 174 | "#{acc.current_prefix}.#{key}" 175 | end 176 | 177 | %{ 178 | paths: Map.put(acc.paths, key_name, value), 179 | current_prefix: acc.current_prefix 180 | } 181 | end 182 | 183 | def _yaml_reducer({key, value}, acc) do 184 | next_prefix = 185 | if acc.current_prefix == "" do 186 | key 187 | else 188 | "#{acc.current_prefix}.#{key}" 189 | end 190 | 191 | reduced = 192 | Enum.reduce( 193 | value, 194 | %{ 195 | paths: acc.paths, 196 | current_prefix: next_prefix 197 | }, 198 | &Linguist.MemorizedVocabulary._yaml_reducer/2 199 | ) 200 | 201 | %{ 202 | paths: Map.merge(acc.paths, reduced.paths), 203 | current_prefix: acc.current_prefix 204 | } 205 | end 206 | 207 | # @privatedoc 208 | # Takes a locale as an argument, checks if the string contains a `-`, if so 209 | # splits the string on the `-` downcases the first part and upcases the second part. 210 | # With a locale that contains no `-` the string is downcased, and if the locale contains more 211 | # than one `-`, a LocaleError is raised. 212 | def normalize_locale(locale) do 213 | if String.match?(locale, ~r/-/) do 214 | case String.split(locale, "-") do 215 | [lang, country] -> 216 | Enum.join([String.downcase(lang), String.upcase(country)], "-") 217 | 218 | _ -> 219 | raise(LocaleError, locale) 220 | end 221 | else 222 | String.downcase(locale) 223 | end 224 | end 225 | end 226 | --------------------------------------------------------------------------------